Error handling em Zig não é exceção escondida, código de retorno esquecido nem macro mágica. A linguagem transforma falhas em parte explícita da assinatura: se uma função pode falhar, o tipo mostra isso. Esse detalhe muda a arquitetura de APIs, testes, logs, cleanup de recursos e até o desenho de serviços em produção.
A prática, porém, exige disciplina. É fácil criar error{Tudo} genérico demais, perder contexto ao converter erros, usar catch unreachable sem justificativa ou fazer retry em falhas que deveriam abortar imediatamente. Este guia atualiza as boas práticas de tratamento de erros em Zig para projetos reais em 2026: try, catch, error unions, error sets, errdefer, mapeamento de erros de dependências, observabilidade e testes.
Se você está começando, leia também a cheatsheet de error handling e as perguntas de entrevista sobre erros. Para aplicar isso em serviços, combine com configuração segura, servidor HTTP em produção, observabilidade em Zig e checklist de code review.
Resumo rápido: como pensar erros em Zig
| Situação | Padrão recomendado | Evite |
|---|---|---|
| Falha esperada que o chamador pode tratar | error set específico + error union | Booleano sem motivo da falha |
| Propagar falha sem adicionar contexto | try chamada() | catch return err redundante |
| Traduzir erro de dependência para domínio da aplicação | catch return error.ConfigInvalida | Vazar erro interno para toda a API |
| Alocar ou abrir recursos em etapas | errdefer ao lado de cada aquisição | Bloco de cleanup distante e incompleto |
| Erro impossível por invariante local | catch unreachable com comentário forte | unreachable para esconder dívida técnica |
| Erro em produção | log estruturado + status/ação clara | logar só erro: {} sem contexto |
A regra central: erros devem ser explícitos na borda certa. Dentro de uma camada pequena, preserve detalhes úteis. Na fronteira pública da API, converta para uma superfície estável que o usuário, o serviço HTTP ou o operador consiga entender.
Error unions: falha faz parte do tipo
Uma função que retorna ArquivoError![]const u8 devolve bytes ou um erro do conjunto ArquivoError. O chamador não consegue ignorar isso sem escrever uma decisão explícita.
const std = @import("std");
const ArquivoError = error{
NaoEncontrado,
PermissaoNegada,
MuitoGrande,
IOError,
};
fn lerArquivo(allocator: std.mem.Allocator, caminho: []const u8) ArquivoError![]u8 {
const file = std.fs.cwd().openFile(caminho, .{}) catch |err| switch (err) {
error.FileNotFound => return error.NaoEncontrado,
error.AccessDenied => return error.PermissaoNegada,
else => return error.IOError,
};
defer file.close();
const stat = file.stat() catch return error.IOError;
if (stat.size > 1024 * 1024) return error.MuitoGrande;
return file.readToEndAlloc(allocator, stat.size) catch return error.IOError;
}
O detalhe importante é que o exemplo não exporta todos os erros possíveis de std.fs para o restante da aplicação. A função escolhe uma fronteira: quem chama precisa saber se o arquivo não existe, se falta permissão, se é grande demais ou se houve I/O genérico. O resto é detalhe de implementação.
try para o caminho comum
Use try quando a função atual não tem nada útil a acrescentar ao erro. Isso mantém o caminho feliz legível e evita blocos catch decorativos.
fn carregarTemplate(allocator: std.mem.Allocator) ![]u8 {
const dir = try std.fs.cwd().openDir("templates", .{});
defer dir.close();
return try dir.readFileAlloc(allocator, "email.html", 64 * 1024);
}
Não transforme todo try em catch |err| return err;. O resultado é mais ruído, não mais controle.
catch quando existe uma decisão local
Use catch quando o erro muda o fluxo: fallback, conversão de erro, log, métrica, resposta HTTP, retry ou encerramento controlado.
const AppError = error{
ConfigInvalida,
BancoIndisponivel,
CacheIndisponivel,
};
fn inicializarApp(allocator: std.mem.Allocator) AppError!App {
const config = Config.load(allocator) catch |err| {
std.log.err("configuração inválida: {}", .{err});
return error.ConfigInvalida;
};
errdefer config.deinit();
const db = conectarBanco(config.database_url) catch |err| {
std.log.err("banco indisponível durante boot: {}", .{err});
return error.BancoIndisponivel;
};
errdefer db.close();
const cache = conectarCache(config.redis_url) catch {
// Cache é útil, mas a aplicação pode subir sem ele.
null;
};
return .{ .config = config, .db = db, .cache = cache };
}
Essa diferença entre erro fatal e degradação aceitável precisa estar no código. Em produção, a escolha afeta deploy, rollback, alerta e experiência do usuário.
Error sets pequenos e semânticos
Um erro de domínio deve dizer o que a aplicação pode fazer, não apenas qual biblioteca falhou.
const CriarUsuarioError = error{
EmailInvalido,
UsuarioJaExiste,
BancoIndisponivel,
LimiteAtingido,
};
fn criarUsuario(input: CriarUsuarioInput) CriarUsuarioError!Usuario {
if (!emailValido(input.email)) return error.EmailInvalido;
inserirUsuario(input) catch |err| switch (err) {
error.UniqueViolation => return error.UsuarioJaExiste,
error.ConnectionRefused, error.Timeout => return error.BancoIndisponivel,
error.TooManyConnections => return error.LimiteAtingido,
else => return error.BancoIndisponivel,
};
return Usuario{ .email = input.email };
}
Esse padrão aparece em integrações com SQLite, PostgreSQL e Redis em Zig: a camada de infraestrutura conhece sqlite3, libpq ou socket errors; a camada de produto conhece UsuarioJaExiste, BancoIndisponivel e LimiteAtingido.
errdefer: cleanup perto da aquisição
errdefer executa apenas se o escopo sair com erro. Ele é uma das ferramentas mais importantes de Zig para código que aloca memória, abre arquivos, inicializa sockets ou monta estruturas em etapas.
const Servidor = struct {
allocator: std.mem.Allocator,
buffer: []u8,
log_file: std.fs.File,
listener: std.net.Server,
pub fn init(allocator: std.mem.Allocator, addr: std.net.Address) !Servidor {
const buffer = try allocator.alloc(u8, 16 * 1024);
errdefer allocator.free(buffer);
const log_file = try std.fs.cwd().createFile("server.log", .{ .truncate = false });
errdefer log_file.close();
var listener = try addr.listen(.{ .reuse_address = true });
errdefer listener.deinit();
return .{
.allocator = allocator,
.buffer = buffer,
.log_file = log_file,
.listener = listener,
};
}
pub fn deinit(self: *Servidor) void {
self.listener.deinit();
self.log_file.close();
self.allocator.free(self.buffer);
}
};
O padrão é simples: adquiriu recurso, escreva o cleanup imediatamente na linha seguinte. Se uma etapa futura falhar, os recursos anteriores serão liberados na ordem inversa. Se tudo der certo, errdefer não roda e a responsabilidade passa para deinit.
defer e errdefer juntos
Use defer para cleanup que sempre acontece dentro do escopo atual. Use errdefer quando o recurso só deve ser limpo se a construção falhar antes da posse ser transferida.
fn copiarArquivo(allocator: std.mem.Allocator, origem: []const u8, destino: []const u8) !void {
const in = try std.fs.cwd().openFile(origem, .{});
defer in.close();
const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{destino});
defer allocator.free(tmp_path);
const out = try std.fs.cwd().createFile(tmp_path, .{});
defer out.close();
var buffer = std.ArrayList(u8).init(allocator);
defer buffer.deinit();
var tmp: [4096]u8 = undefined;
while (true) {
const n = try in.read(&tmp);
if (n == 0) break;
try buffer.appendSlice(tmp[0..n]);
}
try out.writeAll(buffer.items);
try std.fs.cwd().rename(tmp_path, destino);
}
Em funções de construção (init), errdefer costuma dominar. Em funções operacionais que abrem e fecham tudo no mesmo escopo, defer é suficiente.
Fallbacks: só quando o default é seguro
catch com default é útil para configuração opcional, mas perigoso para dados críticos.
fn obterPorta() u16 {
const raw = std.posix.getenv("PORT") orelse return 8080;
return std.fmt.parseInt(u16, raw, 10) catch 8080;
}
fn obterLimiteBody() usize {
const raw = std.posix.getenv("MAX_BODY_BYTES") orelse return 1 * 1024 * 1024;
return std.fmt.parseInt(usize, raw, 10) catch |err| {
std.log.warn("MAX_BODY_BYTES inválido, usando default: {}", .{err});
return 1 * 1024 * 1024;
};
}
Para segredos, endpoints de banco e flags que mudam segurança, prefira falhar no boot. Um default silencioso em DATABASE_URL ou JWT_SECRET cria incidente, não resiliência.
Retry com backoff: filtre erros transitórios
Retry só ajuda em falhas transitórias. Repetir erro de validação, permissão ou configuração aumenta latência e esconde a causa.
const ConnectError = error{
Timeout,
Recusado,
DnsFalhou,
ConfigInvalida,
};
fn transitorio(err: ConnectError) bool {
return switch (err) {
error.Timeout, error.Recusado, error.DnsFalhou => true,
error.ConfigInvalida => false,
};
}
fn conectarComRetry(endereco: []const u8) ConnectError!Conexao {
var tentativa: u32 = 0;
var delay_ms: u64 = 100;
while (tentativa < 5) : (tentativa += 1) {
return conectar(endereco) catch |err| {
if (!transitorio(err)) return err;
if (tentativa == 4) return err;
std.log.warn("conexão falhou, retry em {d}ms: {}", .{ delay_ms, err });
std.time.sleep(delay_ms * std.time.ns_per_ms);
delay_ms *= 2;
continue;
};
}
unreachable;
}
Em serviços HTTP, acrescente jitter e limite global para não transformar uma queda parcial em tempestade de retries.
Logs, métricas e status HTTP
Erros de Zig ficam melhores quando atravessam uma camada de observabilidade consistente. Um handler HTTP pode mapear erro de domínio para status, log e métrica sem vazar detalhe interno.
fn responderErro(err: CriarUsuarioError) HttpResponse {
return switch (err) {
error.EmailInvalido => .{ .status = 400, .body = "email inválido" },
error.UsuarioJaExiste => .{ .status = 409, .body = "usuário já existe" },
error.LimiteAtingido => .{ .status = 429, .body = "tente novamente mais tarde" },
error.BancoIndisponivel => .{ .status = 503, .body = "serviço indisponível" },
};
}
O mesmo switch pode incrementar contadores como http_errors_total{kind="usuario_ja_existe"} e registrar trace_id. Não coloque SQL, path absoluto, token ou payload sensível na resposta. Para isso, siga as práticas de configuração segura e logs sem segredo.
Teste o caminho de erro
Código Zig costuma testar o caminho feliz com facilidade. O diferencial é testar falhas esperadas: allocator que falha, arquivo ausente, JSON inválido, conexão recusada e recursos parcialmente inicializados.
test "parser rejeita JSON vazio" {
try std.testing.expectError(error.BodyVazio, parseBody(""));
}
test "init libera memória quando segunda etapa falha" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer std.testing.expect(gpa.deinit() == .ok) catch @panic("leak detectado");
const allocator = gpa.allocator();
try std.testing.expectError(error.ConfigInvalida, initComConfigRuim(allocator));
}
Combine isso com a cheatsheet de testing e com o checklist de code review: todo PR que mexe em inicialização, memória ou I/O deve provar pelo menos um caminho de falha.
Anti-padrões comuns
Ignorar erro silenciosamente
// Ruim: desaparece com a falha.
_ = enviarMetrica() catch {};
// Melhor: documenta a decisão operacional.
enviarMetrica() catch |err| {
std.log.warn("falha ao enviar métrica não crítica: {}", .{err});
};
Ignorar erro pode ser correto para telemetria best-effort, mas o motivo precisa ficar claro.
catch unreachable por preguiça
// Ruim: se o arquivo sumir em produção, o processo panica.
const file = std.fs.cwd().openFile("config.json", .{}) catch unreachable;
Use unreachable apenas quando a invariante é realmente local e provada. Em inicialização, erro de arquivo/configuração deve virar falha clara de boot.
Error set grande demais
Um AppError usado por todo o monólito parece simples, mas vira acoplamento. Prefira error sets por fronteira: ConfigError, DbError, HttpHandlerError, JobError. Na borda externa, converta para uma superfície menor.
Log duplicado em todas as camadas
Se cada função faz std.log.err e repropaga, uma falha vira dez linhas iguais. Logue no ponto que tem contexto operacional: handler, worker, boot step ou boundary de integração.
Checklist para produção
Antes de publicar uma biblioteca, CLI ou serviço Zig, revise:
- error sets têm nomes semânticos e pequenos?
- erros de dependências são convertidos na fronteira certa?
errdeferfica imediatamente depois de cada aquisição de recurso?- caminhos de falha têm testes com
expectError? - defaults são seguros e documentados?
- retries filtram apenas erros transitórios?
- respostas HTTP não vazam detalhe interno?
- logs têm contexto suficiente sem incluir segredo?
catch unreachabletem justificativa real?- code review verifica memória, cleanup e erro juntos?
Conclusão
O sistema de erros de Zig é poderoso porque obriga o projeto a decidir. try mantém o caminho comum limpo; catch explicita fallback e conversão; errdefer evita vazamento em construção parcial; error sets pequenos transformam falhas técnicas em decisões de produto e operação.
Para comparar abordagens, veja como Rust usa Result e pattern matching, e como Go adota retorno múltiplo com if err != nil. Zig fica no meio: explícito como Go, mas com tipos de erro que o compilador entende.
Conteúdo relacionado
- Cheatsheet de Error Handling — referência rápida de
try,catch,errdefere error sets - Tratamento de Erros em Zig — tutorial passo a passo
- Checklist de Code Review em Zig — revisão de memória, erros, segurança e testes
- Configuração Segura em Zig — falha de boot, env vars e logs sem segredo
- Observabilidade em Zig — logs, métricas, traces e alertas
- Perguntas de Error Handling — preparação para entrevistas