Nesta aula, vamos explorar técnicas avançadas de tratamento de erros em JavaScript. Você já conhece o básico: try/catch/finally, o objeto Error e o lançamento de exceções com throw. Agora, vamos elevar esse conhecimento para lidar com cenários mais complexos e escrever código mais robusto. Vamos aprender a criar erros customizados que carregam informações semânticas, a relançar erros de forma controlada, a tratar erros em operações assíncronas (promises, async/await, callbacks) e a aplicar padrões de design que tornam o tratamento de erros consistente e previsível.

Ao final, você será capaz de projetar sistemas que se recuperam de falhas de forma elegante, que fornecem mensagens claras para o usuário e que mantêm a integridade do fluxo de execução, mesmo diante de imprevistos.

Erros customizados

O JavaScript fornece a classe nativa Error e suas subclasses (como TypeError, RangeError, etc.), mas muitas vezes precisamos de erros que representem problemas específicos do nosso domínio. Por exemplo, em uma aplicação de banco, podemos querer lançar um erro SaldoInsuficienteError ou ContaNaoEncontradaError. Criar erros customizados traz clareza ao código, facilita o tratamento seletivo e permite adicionar propriedades úteis.

Para criar um erro customizado, estendemos a classe Error e definimos um construtor que chama super() com a mensagem. É importante também definir o name e, idealmente, ajustar o prototype para que a herança funcione corretamente (embora em ES6 com classes isso já seja feito automaticamente). Podemos adicionar propriedades específicas, como um código de erro ou dados adicionais.

class SaldoInsuficienteError extends Error {
  constructor(valor, saldoAtual) {
    super(`Saldo insuficiente: necessário ${valor}, disponível ${saldoAtual}`);
    this.name = 'SaldoInsuficienteError';
    this.valor = valor;
    this.saldoAtual = saldoAtual;
    this.codigo = 'E_SALDO_INSUFICIENTE';
  }
}

// Uso
try {
  throw new SaldoInsuficienteError(100, 50);
} catch (erro) {
  console.log(erro.name);        // SaldoInsuficienteError
  console.log(erro.message);     // Saldo insuficiente: necessário 100, disponível 50
  console.log(erro.codigo);      // E_SALDO_INSUFICIENTE
  console.log(erro instanceof Error); // true
  console.log(erro instanceof SaldoInsuficienteError); // true
}

Além de estender Error, é comum criar uma hierarquia de erros: uma classe base para erros de negócio e subclasses para casos específicos. Isso permite capturar todos os erros de negócio de uma vez no catch, ou tratá-los individualmente. Veja um exemplo:

class ErroDeNegocio extends Error {
  constructor(mensagem, codigo) {
    super(mensagem);
    this.name = 'ErroDeNegocio';
    this.codigo = codigo;
  }
}

class ContaNaoEncontradaError extends ErroDeNegocio {
  constructor(idConta) {
    super(`Conta com id ${idConta} não encontrada`, 'E_CONTA_NAO_ENCONTRADA');
    this.name = 'ContaNaoEncontradaError';
    this.idConta = idConta;
  }
}

class SaldoInsuficienteError extends ErroDeNegocio {
  constructor(valor, saldoAtual) {
    super(`Saldo insuficiente: necessário ${valor}, disponível ${saldoAtual}`, 'E_SALDO_INSUFICIENTE');
    this.name = 'SaldoInsuficienteError';
    this.valor = valor;
    this.saldoAtual = saldoAtual;
  }
}

function sacar(contaId, valor) {
  const conta = buscarConta(contaId); // pode lançar ContaNaoEncontradaError
  if (conta.saldo < valor) {
    throw new SaldoInsuficienteError(valor, conta.saldo);
  }
  // ...
}

try {
  sacar(123, 500);
} catch (erro) {
  if (erro instanceof ErroDeNegocio) {
    console.log('Erro de negócio:', erro.codigo, erro.message);
  } else {
    console.log('Erro inesperado:', erro);
  }
}

Re-throw

Re-throw significa capturar um erro em um bloco catch, realizar alguma ação (como logar ou adicionar contexto) e relançá-lo para que um nível superior possa tratá-lo. Isso é útil quando você não quer engolir o erro, mas precisa de informações adicionais ou de uma limpeza antes de propagar.

A técnica é simples: dentro do catch, usamos throw erro (ou throw new Error(... , { cause: erro }) para encadear). Com o ES2022, a opção cause permite anexar o erro original, facilitando a rastreabilidade.

function processarArquivo(caminho) {
  try {
    const conteudo = lerArquivo(caminho);
    // processa...
  } catch (erro) {
    console.error(`Erro ao processar ${caminho}:`, erro);
    // Re-throw com contexto adicional
    throw new Error(`Falha ao processar arquivo ${caminho}`, { cause: erro });
  }
}

try {
  processarArquivo('dados.txt');
} catch (erro) {
  console.log('Capturado no nível superior:', erro.message);
  console.log('Causa original:', erro.cause);
}

Outra situação comum é capturar um erro, verificar se é do tipo que você pode tratar; se não, relançar. Isso é chamado de filtro de exceção. Por exemplo, em uma aplicação que lida com números, você pode querer tratar apenas erros de validação e relançar erros de infraestrutura.

function calcularMedia(numeros) {
  try {
    if (!Array.isArray(numeros) || numeros.length === 0) {
      throw new TypeError('numeros deve ser um array não vazio');
    }
    const soma = numeros.reduce((acc, num) => {
      if (typeof num !== 'number') {
        throw new TypeError('Todos os elementos devem ser números');
      }
      return acc + num;
    }, 0);
    return soma / numeros.length;
  } catch (erro) {
    if (erro instanceof TypeError) {
      throw erro; // re-throw, pois é um erro de validação
    }
    // Para outros erros, podemos logar e re-lançar com contexto
    console.error('Erro inesperado:', erro);
    throw new Error('Falha ao calcular média', { cause: erro });
  }
}

Erros assíncronos

Em JavaScript, erros podem ocorrer em operações assíncronas: callbacks, promises e async/await. O tratamento correto varia conforme o mecanismo. Vamos explorar cada um.

Callbacks

Em callbacks, é convenção usar o primeiro argumento para o erro (padrão do Node.js). O callback deve verificar se há erro antes de prosseguir. Exemplo:

const fs = require('fs');

fs.readFile('arquivo.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('Erro ao ler arquivo:', err);
    return;
  }
  console.log('Conteúdo:', data);
});

Se você lançar um erro dentro de um callback, ele não será capturado por um try/catch externo, pois o callback é executado em um contexto diferente. Portanto, sempre trate o erro dentro do callback ou use promessas.

Promises

Com promises, usamos .catch() para tratar rejeições. Se uma promise rejeita e não há catch, o erro se torna um unhandled rejection (em Node.js, pode derrubar o processo). É crucial sempre encadear um catch.

function buscarDados() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const sucesso = Math.random() > 0.5;
      if (sucesso) {
        resolve({ id: 1, nome: 'João' });
      } else {
        reject(new Error('Falha ao buscar dados'));
      }
    }, 1000);
  });
}

buscarDados()
  .then(dados => console.log(dados))
  .catch(erro => console.error('Erro:', erro.message));

Além disso, em uma cadeia de promises, um erro em qualquer elo é propagado até o catch final.

Async/Await

Com async/await, usamos try/catch normalmente. O await dentro de try captura rejeições da promise. Se não houver catch, o erro se propaga para a função chamadora (se for async) ou vira uma rejeição não tratada.

async function processar() {
  try {
    const dados = await buscarDados();
    console.log(dados);
  } catch (erro) {
    console.error('Erro no processamento:', erro.message);
  }
}

processar();

Uma dica: ao usar async/await, não se esqueça de envolver chamadas assíncronas em try/catch, ou usar .catch na promise retornada. Em código moderno, prefira async/await para legibilidade.

Eventos e streams

Em Node.js, muitos objetos emitem eventos de erro. Se um erro for emitido e não houver listener, o processo pode crashar. Sempre adicione um listener para o evento 'error'.

const EventEmitter = require('events');

const meuEmitter = new EventEmitter();
meuEmitter.on('error', (erro) => {
  console.error('Erro capturado:', erro.message);
});

meuEmitter.emit('error', new Error('Algo deu errado'));

Padrões

Existem padrões de design que ajudam a lidar com erros de forma consistente. Vamos ver alguns importantes.

Result Object

Em vez de lançar exceções, uma função pode retornar um objeto que representa sucesso ou falha, evitando o fluxo excepcional. Isso é comum em linguagens funcionais e pode ser implementado com um simples objeto.

function dividir(a, b) {
  if (b === 0) {
    return { ok: false, erro: new Error('Divisão por zero') };
  }
  return { ok: true, valor: a / b };
}

const resultado = dividir(10, 0);
if (!resultado.ok) {
  console.error('Erro:', resultado.erro.message);
} else {
  console.log('Resultado:', resultado.valor);
}

Esse padrão é útil quando o erro não deve interromper o fluxo principal e quando você quer evitar try/catch em muitas camadas.

Error Boundaries (em frameworks)

Em React, por exemplo, usamos Error Boundaries para capturar erros em componentes filhos e exibir uma UI alternativa. Embora não seja JavaScript puro, o conceito de fronteiras de erro pode ser aplicado em qualquer sistema, capturando erros em um nível e exibindo uma resposta amigável.

// Exemplo conceitual em React
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Erro capturado:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Algo deu errado.</h1>;
    }
    return this.props.children;
  }
}

Centralização de erros

Em aplicações maiores, é comum ter um módulo central de tratamento de erros que registra logs, envia para serviços de monitoramento (como Sentry) e decide a resposta apropriada. Isso evita repetição de lógica de tratamento.

// modulo-erros.js
class GerenciadorDeErros {
  static tratar(erro) {
    console.error('Log:', erro);
    // enviar para serviço de monitoramento
    // decidir resposta ao usuário
    if (erro instanceof ErroDeNegocio) {
      return { status: 400, mensagem: erro.message };
    }
    return { status: 500, mensagem: 'Erro interno' };
  }
}

// uso em uma rota express
app.use((err, req, res, next) => {
  const resposta = GerenciadorDeErros.tratar(err);
  res.status(resposta.status).json({ mensagem: resposta.mensagem });
});

Padrão Fail Fast

O padrão Fail Fast defende que o sistema deve falhar imediatamente quando uma condição inválida é detectada, em vez de continuar com dados corrompidos. Isso é implementado com validações no início de funções, lançando erros cedo.

function processarPagamento(valor, moeda) {
  if (typeof valor !== 'number' || valor <= 0) {
    throw new TypeError('Valor deve ser um número positivo');
  }
  if (!['BRL', 'USD', 'EUR'].includes(moeda)) {
    throw new RangeError('Moeda inválida');
  }
  // ...
}

Boas práticas e observações finais

Ao trabalhar com erros, lembre-se de sempre fornecer mensagens claras e acionáveis, não exponha detalhes internos em mensagens para o usuário final, mas registre-os para diagnóstico. Use erros customizados para diferenciar falhas previsíveis de exceções inesperadas. Em código assíncrono, evite criar promessas sem tratamento de rejeição; use async/await com try/catch para legibilidade. E nunca deixe um catch vazio — ao menos registre o erro.

Referências

Exercícios

  1. Exercício 1: Crie uma classe de erro customizada chamada ValidacaoError que estenda Error e adicione uma propriedade campo (string). Instancie-a e capture em um try/catch, imprimindo a mensagem e o campo.

    ✓ Resposta:
    class ValidacaoError extends Error {
      constructor(mensagem, campo) {
        super(mensagem);
        this.name = 'ValidacaoError';
        this.campo = campo;
      }
    }
    
    try {
      throw new ValidacaoError('Campo obrigatório', 'nome');
    } catch (erro) {
      console.log(erro.message); // Campo obrigatório
      console.log(erro.campo);   // nome
    }
  2. Exercício 2: Escreva uma função que lê um JSON de um arquivo, mas se o arquivo não existir, relança um erro com uma mensagem mais amigável e com a causa original. Use a opção cause.

    ✓ Resposta:
    const fs = require('fs');
    
    function lerJSON(caminho) {
      try {
        const conteudo = fs.readFileSync(caminho, 'utf8');
        return JSON.parse(conteudo);
      } catch (erro) {
        throw new Error(`Não foi possível ler o arquivo ${caminho}`, { cause: erro });
      }
    }
    
    try {
      const dados = lerJSON('config.json');
    } catch (erro) {
      console.log(erro.message);
      console.log(erro.cause);
    }
  3. Exercício 3: Dada a promise promiseQuePodeFalhar(), crie uma função async que a chame e trate o erro com try/catch. Se a promise rejeitar, imprima a mensagem no console.

    ✓ Resposta:
    async function executar() {
      try {
        const resultado = await promiseQuePodeFalhar();
        console.log('Sucesso:', resultado);
      } catch (erro) {
        console.error('Erro:', erro.message);
      }
    }
    
    // Exemplo de promise
    function promiseQuePodeFalhar() {
      return new Promise((resolve, reject) => {
        setTimeout(() => reject(new Error('Falha proposital')), 100);
      });
    }
    
    executar();
  4. Exercício 4: Implemente uma função parseIntSafe que retorna um objeto { ok: boolean, valor?: number, erro?: Error } em vez de lançar exceção. Use para converter uma string em número, retornando erro se não for possível.

    ✓ Resposta:
    function parseIntSafe(texto) {
      const numero = parseInt(texto, 10);
      if (isNaN(numero)) {
        return { ok: false, erro: new Error(`Não foi possível converter "${texto}" para número`) };
      }
      return { ok: true, valor: numero };
    }
    
    const res = parseIntSafe('123');
    if (res.ok) {
      console.log(res.valor); // 123
    } else {
      console.error(res.erro.message);
    }
  5. Exercício 5: Explique o padrão Fail Fast e dê um exemplo de validação em uma função de cadastro de usuário que lança erros cedo se algum campo estiver inválido.

    ✓ Resposta:O padrão Fail Fast consiste em falhar o mais rápido possível ao detectar uma condição inválida, evitando processamento desnecessário e estados inconsistentes. Exemplo:
    function cadastrarUsuario({ nome, email, idade }) {
      if (!nome || typeof nome !== 'string') {
        throw new TypeError('Nome é obrigatório e deve ser string');
      }
      if (!email || !email.includes('@')) {
        throw new TypeError('Email inválido');
      }
      if (idade < 18) {
        throw new RangeError('Idade mínima é 18');
      }
      // ...
    }