Error Handling Idiomático em Rust: Result, Option, thiserror e anyhow

Guia completo de tratamento de erros em Rust: Result e Option, operador ?, tipos de erro customizados com thiserror, erros de aplicação com anyhow e conversão com From.

O tratamento de erros em Rust é radicalmente diferente de outras linguagens. Não há exceções (exceptions), não há try/catch, não há null. Em vez disso, Rust usa dois tipos algébricos poderosos: Result<T, E> para operações que podem falhar, e Option<T> para valores que podem estar ausentes. Combinados com o operador ?, traits de conversão e crates como thiserror e anyhow, o sistema de erros do Rust é o mais robusto e ergonômico que existe.

A filosofia do Rust é clara: erros são valores, não fluxos de controle excepcionais. Todo erro deve ser tratado explicitamente, e o compilador garante que nenhum caso de erro seja ignorado.

Problema

Em linguagens com exceções, erros podem surgir de qualquer lugar e propagar silenciosamente:

# Python: qualquer linha pode lançar uma exceção
def processar():
    arquivo = abrir("dados.txt")        # IOError?
    dados = json.loads(arquivo.read())   # JSONDecodeError?
    validar(dados)                       # ValueError?
    salvar_no_banco(dados)               # DatabaseError?
    # Quais exceções esta função pode lançar? Ninguém sabe!

O chamador não sabe quais erros esperar. Exceções não checadas propagam silenciosamente, causando crashes em produção.

Solução em Rust

Result e Option — A Base

/// Result: operação que pode falhar
fn dividir(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("Divisão por zero".to_string())
    } else {
        Ok(a / b)
    }
}

/// Option: valor que pode estar ausente
fn buscar_usuario(id: u64) -> Option<String> {
    match id {
        1 => Some("Alice".to_string()),
        2 => Some("Bob".to_string()),
        _ => None,
    }
}

fn main() {
    // Tratamento explícito com match
    match dividir(10.0, 3.0) {
        Ok(resultado) => println!("10/3 = {:.2}", resultado),
        Err(erro) => println!("Erro: {}", erro),
    }

    // Métodos de conveniência
    let resultado = dividir(10.0, 0.0).unwrap_or(0.0);
    println!("Com fallback: {}", resultado);

    // map para transformar o valor de sucesso
    let dobro = dividir(10.0, 2.0).map(|v| v * 2.0);
    println!("Dobro: {:?}", dobro); // Ok(10.0)

    // and_then para encadear operações falíveis
    let resultado = dividir(100.0, 5.0)
        .and_then(|v| dividir(v, 2.0))
        .and_then(|v| dividir(v, 0.0)); // Falha aqui
    println!("Encadeado: {:?}", resultado); // Err("Divisão por zero")

    // Option: tratamento de ausência
    match buscar_usuario(1) {
        Some(nome) => println!("Encontrado: {}", nome),
        None => println!("Usuário não encontrado"),
    }

    // unwrap_or_else com closure
    let nome = buscar_usuario(99)
        .unwrap_or_else(|| "Anônimo".to_string());
    println!("Nome: {}", nome);

    // Convertendo entre Option e Result
    let resultado: Result<String, String> = buscar_usuario(99)
        .ok_or_else(|| "Usuário não encontrado".to_string());
    println!("Como Result: {:?}", resultado);
}

O Operador ? — Propagação Elegante

use std::fs;
use std::num::ParseIntError;

/// Sem o operador ? — verboso e repetitivo
fn ler_numero_sem_interrogacao(caminho: &str) -> Result<i32, String> {
    let conteudo = match fs::read_to_string(caminho) {
        Ok(c) => c,
        Err(e) => return Err(format!("Erro de IO: {}", e)),
    };
    let numero = match conteudo.trim().parse::<i32>() {
        Ok(n) => n,
        Err(e) => return Err(format!("Erro de parse: {}", e)),
    };
    Ok(numero)
}

/// Com o operador ? — limpo e conciso
#[derive(Debug)]
enum ErroLeitura {
    Io(std::io::Error),
    Parse(ParseIntError),
}

impl From<std::io::Error> for ErroLeitura {
    fn from(e: std::io::Error) -> Self {
        ErroLeitura::Io(e)
    }
}

impl From<ParseIntError> for ErroLeitura {
    fn from(e: ParseIntError) -> Self {
        ErroLeitura::Parse(e)
    }
}

impl std::fmt::Display for ErroLeitura {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ErroLeitura::Io(e) => write!(f, "Erro de IO: {}", e),
            ErroLeitura::Parse(e) => write!(f, "Erro de parse: {}", e),
        }
    }
}

/// O operador ? propaga o erro automaticamente,
/// usando as conversões From definidas acima
fn ler_numero(caminho: &str) -> Result<i32, ErroLeitura> {
    let conteudo = fs::read_to_string(caminho)?;  // io::Error -> ErroLeitura
    let numero = conteudo.trim().parse::<i32>()?;  // ParseIntError -> ErroLeitura
    Ok(numero)
}

/// ? também funciona com Option
fn primeiro_par(numeros: &[i32]) -> Option<i32> {
    let primeiro = numeros.first()?;  // None se vazio
    if primeiro % 2 == 0 {
        Some(*primeiro)
    } else {
        None
    }
}

Tipos de Erro Customizados com thiserror

O crate thiserror gera automaticamente as implementações de Display, Error e From:

// Em Cargo.toml: thiserror = "2"

use std::num::ParseIntError;

/// Erros da camada de validação
#[derive(Debug, thiserror::Error)]
enum ErroValidacao {
    #[error("Campo '{campo}' é obrigatório")]
    CampoObrigatorio { campo: String },

    #[error("Email '{0}' tem formato inválido")]
    EmailInvalido(String),

    #[error("Idade {idade} fora do intervalo [{min}, {max}]")]
    IdadeForaDoIntervalo { idade: u32, min: u32, max: u32 },
}

/// Erros da camada de persistência
#[derive(Debug, thiserror::Error)]
enum ErroPersistencia {
    #[error("Registro com ID {0} não encontrado")]
    NaoEncontrado(u64),

    #[error("Violação de unicidade: {campo} = '{valor}'")]
    DuplicataDetectada { campo: String, valor: String },

    #[error("Erro de conexão: {0}")]
    Conexao(String),
}

/// Erro da aplicação — agrega todas as camadas
#[derive(Debug, thiserror::Error)]
enum ErroAplicacao {
    #[error("Erro de validação: {0}")]
    Validacao(#[from] ErroValidacao),

    #[error("Erro de persistência: {0}")]
    Persistencia(#[from] ErroPersistencia),

    #[error("Erro de IO: {0}")]
    Io(#[from] std::io::Error),

    #[error("Erro ao parsear número: {0}")]
    ParseInt(#[from] ParseIntError),

    #[error("Erro interno: {0}")]
    Interno(String),
}

/// Validação de dados do usuário
fn validar_usuario(nome: &str, email: &str, idade: u32) -> Result<(), ErroValidacao> {
    if nome.is_empty() {
        return Err(ErroValidacao::CampoObrigatorio {
            campo: "nome".to_string(),
        });
    }
    if !email.contains('@') || !email.contains('.') {
        return Err(ErroValidacao::EmailInvalido(email.to_string()));
    }
    if idade < 13 || idade > 150 {
        return Err(ErroValidacao::IdadeForaDoIntervalo {
            idade,
            min: 13,
            max: 150,
        });
    }
    Ok(())
}

/// Salvar no banco de dados
fn salvar_usuario(nome: &str, email: &str) -> Result<u64, ErroPersistencia> {
    // Simulando verificação de duplicata
    if email == "admin@exemplo.com" {
        return Err(ErroPersistencia::DuplicataDetectada {
            campo: "email".to_string(),
            valor: email.to_string(),
        });
    }
    println!("[BD] Usuário '{}' salvo", nome);
    Ok(42) // ID do registro
}

/// Função que integra todas as camadas — usa ? com conversão automática
fn registrar_usuario(
    nome: &str,
    email: &str,
    idade: u32,
) -> Result<u64, ErroAplicacao> {
    // ? converte ErroValidacao -> ErroAplicacao automaticamente (via From)
    validar_usuario(nome, email, idade)?;

    // ? converte ErroPersistencia -> ErroAplicacao automaticamente
    let id = salvar_usuario(nome, email)?;

    println!("Usuário registrado com ID {}", id);
    Ok(id)
}

fn main() {
    // Sucesso
    match registrar_usuario("Maria", "maria@exemplo.com", 28) {
        Ok(id) => println!("Sucesso! ID: {}", id),
        Err(e) => println!("Erro: {}", e),
    }

    println!("---");

    // Erro de validação
    match registrar_usuario("", "maria@exemplo.com", 28) {
        Ok(id) => println!("Sucesso! ID: {}", id),
        Err(e) => println!("Erro: {}", e),
    }

    // Erro de persistência
    match registrar_usuario("Admin", "admin@exemplo.com", 30) {
        Ok(id) => println!("Sucesso! ID: {}", id),
        Err(e) => println!("Erro: {}", e),
    }
}

Erros de Aplicação com anyhow

Para aplicações (não bibliotecas), anyhow simplifica o tratamento de erros:

// Em Cargo.toml: anyhow = "1"

use anyhow::{Context, Result, bail, ensure};

/// Com anyhow, Result<T> é equivalente a Result<T, anyhow::Error>
fn carregar_configuracao(caminho: &str) -> Result<Config> {
    let conteudo = std::fs::read_to_string(caminho)
        .context(format!("Falha ao ler arquivo de configuração '{}'", caminho))?;

    let config: Config = serde_json::from_str(&conteudo)
        .context("Falha ao parsear configuração JSON")?;

    ensure!(config.porta > 0, "Porta deve ser positiva, encontrado: {}", config.porta);

    if config.host.is_empty() {
        bail!("Host não pode estar vazio");
    }

    Ok(config)
}

/// Em main(), anyhow formata automaticamente a cadeia de erros
fn main() -> Result<()> {
    let config = carregar_configuracao("config.json")
        .context("Falha ao inicializar aplicação")?;

    println!("Servidor: {}:{}", config.host, config.porta);
    Ok(())
}

// Saída em caso de erro:
// Error: Falha ao inicializar aplicação
//
// Caused by:
//     0: Falha ao ler arquivo de configuração 'config.json'
//     1: No such file or directory (os error 2)

struct Config {
    host: String,
    porta: u16,
}

Diagrama

    Hierarquia de Erros — Camadas da Aplicação:

    ┌─────────────────────────────────────────────┐
    │              ErroAplicacao                    │
    │  (enum raiz — agrega todas as camadas)       │
    │─────────────────────────────────────────────│
    │                                              │
    │  ┌──────────────┐  ┌───────────────────┐    │
    │  │ErroValidacao │  │ ErroPersistencia  │    │
    │  │──────────────│  │───────────────────│    │
    │  │CampoObrig.   │  │NaoEncontrado     │    │
    │  │EmailInvalido │  │DuplicataDetect.  │    │
    │  │IdadeForaInt. │  │Conexao           │    │
    │  └──────┬───────┘  └────────┬──────────┘    │
    │         │ From              │ From           │
    │         └─────────┬─────────┘               │
    │                   ▼                          │
    │           ErroAplicacao                      │
    │                                              │
    │  ┌──────────────┐  ┌───────────────────┐    │
    │  │ std::io::Error│  │ ParseIntError    │    │
    │  └──────┬───────┘  └────────┬──────────┘    │
    │         │ From              │ From           │
    │         └─────────┬─────────┘               │
    │                   ▼                          │
    │           ErroAplicacao                      │
    └─────────────────────────────────────────────┘

    Operador ? — Fluxo de Propagação:

    fn camada_3() -> Result<T, Erro3> { ... }
    fn camada_2() -> Result<T, Erro2> {
        let val = camada_3()?;  ─── Err(e3) ──→ From::from(e3) ──→ Err(Erro2)
        Ok(val)                 ─── Ok(v)   ──→ v (continua)
    }
    fn camada_1() -> Result<T, Erro1> {
        let val = camada_2()?;  ─── Err(e2) ──→ From::from(e2) ──→ Err(Erro1)
        Ok(val)
    }

    thiserror vs anyhow:

    ┌─────────────┐          ┌─────────────┐
    │  thiserror   │          │   anyhow     │
    │─────────────│          │─────────────│
    │ Bibliotecas  │          │ Aplicações   │
    │ Erros tipados│          │ Erros dinâm. │
    │ Enum+Display │          │ Box<dyn Error>│
    │ Pattern match│          │ .context()   │
    └─────────────┘          └─────────────┘

Exemplo do Mundo Real

Um sistema de processamento de pedidos com múltiplas camadas de erros:

use std::collections::HashMap;
use std::fmt;

// ============================================================
// Camada 1: Erros de IO/Infraestrutura
// ============================================================

#[derive(Debug)]
enum ErroInfra {
    ArquivoNaoEncontrado(String),
    TimeoutConexao { host: String, porta: u16 },
    PermissaoNegada(String),
}

impl fmt::Display for ErroInfra {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErroInfra::ArquivoNaoEncontrado(p) => write!(f, "Arquivo não encontrado: {}", p),
            ErroInfra::TimeoutConexao { host, porta } => {
                write!(f, "Timeout ao conectar em {}:{}", host, porta)
            }
            ErroInfra::PermissaoNegada(r) => write!(f, "Permissão negada: {}", r),
        }
    }
}

// ============================================================
// Camada 2: Erros de Negócio
// ============================================================

#[derive(Debug)]
enum ErroNegocio {
    EstoqueInsuficiente { produto: String, disponivel: u32, solicitado: u32 },
    LimiteCredito { cliente: String, limite: f64, valor: f64 },
    ProdutoInativo(String),
}

impl fmt::Display for ErroNegocio {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErroNegocio::EstoqueInsuficiente { produto, disponivel, solicitado } => {
                write!(f, "Estoque insuficiente para '{}': {} disponível, {} solicitado",
                    produto, disponivel, solicitado)
            }
            ErroNegocio::LimiteCredito { cliente, limite, valor } => {
                write!(f, "Limite de crédito excedido para '{}': limite R${:.2}, valor R${:.2}",
                    cliente, limite, valor)
            }
            ErroNegocio::ProdutoInativo(p) => write!(f, "Produto inativo: {}", p),
        }
    }
}

// ============================================================
// Camada 3: Erro do Serviço (agrega as camadas)
// ============================================================

#[derive(Debug)]
enum ErroServico {
    Infra(ErroInfra),
    Negocio(ErroNegocio),
    Desconhecido(String),
}

impl From<ErroInfra> for ErroServico {
    fn from(e: ErroInfra) -> Self { ErroServico::Infra(e) }
}

impl From<ErroNegocio> for ErroServico {
    fn from(e: ErroNegocio) -> Self { ErroServico::Negocio(e) }
}

impl fmt::Display for ErroServico {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ErroServico::Infra(e) => write!(f, "[INFRA] {}", e),
            ErroServico::Negocio(e) => write!(f, "[NEGÓCIO] {}", e),
            ErroServico::Desconhecido(msg) => write!(f, "[DESCONHECIDO] {}", msg),
        }
    }
}

// ============================================================
// Serviço de Pedidos
// ============================================================

struct Estoque {
    produtos: HashMap<String, (u32, f64, bool)>, // (qtd, preço, ativo)
}

impl Estoque {
    fn verificar(&self, produto: &str, qtd: u32) -> Result<f64, ErroNegocio> {
        match self.produtos.get(produto) {
            None => Err(ErroNegocio::ProdutoInativo(produto.to_string())),
            Some((_, _, false)) => Err(ErroNegocio::ProdutoInativo(produto.to_string())),
            Some((disponivel, preco, true)) => {
                if *disponivel < qtd {
                    Err(ErroNegocio::EstoqueInsuficiente {
                        produto: produto.to_string(),
                        disponivel: *disponivel,
                        solicitado: qtd,
                    })
                } else {
                    Ok(*preco * qtd as f64)
                }
            }
        }
    }
}

fn processar_pedido(
    estoque: &Estoque,
    itens: &[(&str, u32)],
    cliente: &str,
    limite_credito: f64,
) -> Result<f64, ErroServico> {
    let mut total = 0.0;

    for (produto, qtd) in itens {
        // ? converte ErroNegocio -> ErroServico automaticamente
        let subtotal = estoque.verificar(produto, *qtd)?;
        total += subtotal;
    }

    if total > limite_credito {
        return Err(ErroNegocio::LimiteCredito {
            cliente: cliente.to_string(),
            limite: limite_credito,
            valor: total,
        })?;
    }

    println!("Pedido aprovado para '{}': R${:.2}", cliente, total);
    Ok(total)
}

fn main() {
    let mut produtos = HashMap::new();
    produtos.insert("Teclado".to_string(), (10, 250.0, true));
    produtos.insert("Mouse".to_string(), (5, 80.0, true));
    produtos.insert("Monitor".to_string(), (0, 1500.0, true));
    produtos.insert("Webcam".to_string(), (3, 200.0, false));

    let estoque = Estoque { produtos };

    // Pedido bem-sucedido
    let itens = vec![("Teclado", 2), ("Mouse", 1)];
    match processar_pedido(&estoque, &itens, "João", 1000.0) {
        Ok(total) => println!("Total: R${:.2}", total),
        Err(e) => println!("Erro: {}", e),
    }

    println!("---");

    // Estoque insuficiente
    let itens = vec![("Monitor", 1)];
    match processar_pedido(&estoque, &itens, "Maria", 5000.0) {
        Ok(total) => println!("Total: R${:.2}", total),
        Err(e) => println!("Erro: {}", e),
    }

    // Produto inativo
    let itens = vec![("Webcam", 1)];
    match processar_pedido(&estoque, &itens, "Pedro", 1000.0) {
        Ok(total) => println!("Total: R${:.2}", total),
        Err(e) => println!("Erro: {}", e),
    }

    // Limite de crédito excedido
    let itens = vec![("Teclado", 5)];
    match processar_pedido(&estoque, &itens, "Ana", 500.0) {
        Ok(total) => println!("Total: R${:.2}", total),
        Err(e) => println!("Erro: {}", e),
    }
}

Quando Usar

  • Sempre: Em Rust, tratamento de erros com Result/Option é o padrão — não é opcional
  • thiserror: Em bibliotecas onde os consumidores precisam fazer pattern match nos erros
  • anyhow: Em aplicações onde você quer propagar erros com contexto
  • Erros customizados: Quando os erros padrão do Rust não expressam a semântica do seu domínio
  • Conversão From: Quando múltiplas fontes de erro precisam ser unificadas

Quando NÃO Usar

  • Panic para bugs: Use panic! para bugs do programador (índice fora do limite, invariante violada), não para erros esperados
  • unwrap em produção: Evite .unwrap() em código de produção — use .expect("mensagem clara") no mínimo
  • Erros excessivamente granulares: Se cada função tem 10 variantes de erro, considere agrupar em categorias
  • anyhow em bibliotecas: Bibliotecas devem usar tipos de erro tipados (thiserror), não erros dinâmicos

Variações em Rust

Combinadores avançados

fn demonstrar_combinadores() {
    let numeros = vec!["1", "2", "abc", "4", "def"];

    // collect() pode coletar Result<Vec<T>, E> — para no primeiro erro
    let resultado: Result<Vec<i32>, _> = numeros.iter()
        .map(|s| s.parse::<i32>())
        .collect();
    println!("Collect (para no erro): {:?}", resultado);

    // filter_map para ignorar erros
    let apenas_validos: Vec<i32> = numeros.iter()
        .filter_map(|s| s.parse::<i32>().ok())
        .collect();
    println!("Apenas válidos: {:?}", apenas_validos);

    // partition para separar sucessos de erros
    let (sucessos, erros): (Vec<_>, Vec<_>) = numeros.iter()
        .map(|s| s.parse::<i32>())
        .partition(|r| r.is_ok());
    println!("Sucessos: {}, Erros: {}", sucessos.len(), erros.len());
}

Erros com backtrace

// Com a feature std::backtrace (estável desde Rust 1.65)
use std::backtrace::Backtrace;

#[derive(Debug)]
struct ErroComBacktrace {
    mensagem: String,
    backtrace: Backtrace,
}

impl ErroComBacktrace {
    fn novo(msg: &str) -> Self {
        ErroComBacktrace {
            mensagem: msg.to_string(),
            backtrace: Backtrace::capture(),
        }
    }
}

impl fmt::Display for ErroComBacktrace {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}\n\nBacktrace:\n{}", self.mensagem, self.backtrace)
    }
}

Padrões Relacionados

  • Newtype: Newtypes validados retornam Result na construção
  • Builder: build() retorna Result quando campos obrigatórios podem estar faltando
  • RAII: Guards com Drop para cleanup em caminhos de erro
  • State: Transições inválidas retornam Result com erro descritivo

Conclusão

O sistema de tratamento de erros do Rust, centrado em Result<T, E> e Option<T>, é fundamentalmente superior a exceções. Erros são valores explícitos que o compilador obriga você a tratar. O operador ? torna a propagação ergonômica, thiserror simplifica a criação de tipos de erro em bibliotecas, e anyhow oferece flexibilidade para aplicações. A combinação dessas ferramentas resulta em código que expressa claramente seus modos de falha, propaga erros de forma elegante e garante que nenhum caso de erro seja silenciosamente ignorado.