O tratamento de erros é uma parte fundamental do desenvolvimento de software. Em JavaScript, dominar o uso de try/catch e throw é essencial, mas para construir aplicações robustas, precisamos ir além: criar erros customizados, re-lançar erros para tratamento em camadas superiores, lidar com erros em operações assíncronas (como Promises e async/await) e adotar padrões eficientes.

Nesta aula, vamos explorar essas técnicas avançadas, com exemplos práticos e boas práticas, para que você possa escrever código mais resiliente e fácil de depurar.

Erros customizados

Em aplicações reais, os erros nativos (como TypeError, RangeError) nem sempre são suficientes para expressar problemas específicos do domínio. Erros customizados permitem criar classes de erro que carregam informações adicionais e podem ser identificadas de forma única.

JavaScript permite criar erros personalizados estendendo a classe Error. Isso é feito usando a sintaxe de classes (ES6). Ao estender Error, devemos chamar super() com a mensagem e definir o nome do erro. Também podemos adicionar propriedades extras, como códigos de erro ou dados contextuais.

class ValidationError extends Error {
  constructor(mensagem, campo) {
    super(mensagem);
    this.name = "ValidationError";
    this.campo = campo;
  }
}

try {
  throw new ValidationError("O campo email é obrigatório", "email");
} catch (erro) {
  if (erro instanceof ValidationError) {
    console.error(`Erro de validação no campo ${erro.campo}: ${erro.message}`);
  } else {
    console.error("Erro desconhecido", erro);
  }
}

Usar instanceof é a maneira mais confiável de verificar o tipo de erro, pois a propriedade name pode ser alterada. Além disso, podemos criar hierarquias de erros, como uma classe base AppError e subclasses mais específicas.

Re-throw

O re-throw (relançamento) é uma técnica usada quando você deseja tratar parcialmente um erro em um nível e depois lançá-lo novamente para que outro nível possa tratá-lo. Isso é útil em camadas de abstração, onde um erro pode ser enriquecido com contexto adicional antes de ser propagado.

No bloco catch, você pode executar alguma ação (como logar) e depois usar throw erro ou throw new ErroCustomizado(...) para relançar. O erro original pode ser preservado como propriedade cause (em ES2022) ou como uma propriedade personalizada.

function processarDados(dados) {
  try {
    // Simula uma operação que pode falhar
    if (!dados.nome) {
      throw new ValidationError("Nome é obrigatório", "nome");
    }
    // ... processamento
  } catch (erro) {
    console.error("Falha ao processar dados", erro);
    // Relança com contexto adicional
    throw new Error("Falha na camada de processamento", { cause: erro });
  }
}

try {
  processarDados({});
} catch (erro) {
  console.error("Erro capturado no nível superior:", erro.message);
  console.error("Causa original:", erro.cause);
}

O re-throw é uma prática comum em middleware, serviços e controladores, onde cada camada adiciona informações relevantes ao erro antes de repassá-lo.

Erros assíncronos

Erros em operações assíncronas são um dos maiores desafios em JavaScript. Em Promises, usamos catch() para tratar rejeições. Em async/await, usamos try/catch que funciona de forma síncrona, mas precisa capturar erros de operações assíncronas dentro do bloco.

É importante lembrar que um erro lançado dentro de um async function resulta em uma Promise rejeitada. Portanto, se você não usar await ou não capturar a rejeição, o erro pode se tornar uma rejeição não tratada.

async function buscarUsuario(id) {
  const resposta = await fetch(`/api/usuarios/${id}`);
  if (!resposta.ok) {
    throw new Error("Falha na requisição");
  }
  return resposta.json();
}

// Tratamento com try/catch
async function main() {
  try {
    const usuario = await buscarUsuario(1);
    console.log(usuario);
  } catch (erro) {
    console.error("Erro ao buscar usuário:", erro.message);
  }
}

// Tratamento com .catch()
buscarUsuario(1)
  .then(usuario => console.log(usuario))
  .catch(erro => console.error("Erro:", erro.message));

Para evitar rejeições não tratadas, sempre adicione um catch em cada cadeia de Promises ou use try/catch em funções assíncronas. Em Node.js, também é possível capturar erros globais com process.on('unhandledRejection').

Padrões

Existem padrões consagrados para tratamento de erros que ajudam a manter o código limpo e previsível. Um deles é o padrão de resultado (Result Pattern), onde as funções retornam um objeto com sucesso ou erro, em vez de lançar exceções. Isso é comum em linguagens funcionais e pode ser aplicado em JavaScript.

Outro padrão é usar um wrapper para capturar erros em funções assíncronas, evitando repetição de try/catch. Por exemplo, um helper que envolve uma função e retorna uma Promise que sempre resolve com um array [dado, erro].

function tryCatch(fn) {
  return async (...args) => {
    try {
      const data = await fn(...args);
      return [data, null];
    } catch (erro) {
      return [null, erro];
    }
  };
}

const buscarUsuarioSeguro = tryCatch(buscarUsuario);

const [usuario, erro] = await buscarUsuarioSeguro(1);
if (erro) {
  console.error("Erro:", erro.message);
} else {
  console.log("Usuário:", usuario);
}

Também é comum centralizar o tratamento de erros em um middleware (em Express) ou em um handler global que formata a resposta de erro para o cliente. Isso evita repetição e garante consistência.

Boas práticas

  • Sempre use instanceof para verificar tipos de erro, em vez de comparar name.
  • Enriqueça erros com propriedades úteis (código, campo, causa) para facilitar a depuração.
  • Não engula erros silenciosamente; sempre logue ou trate de alguma forma.
  • Em código assíncrono, garanta que todas as rejeições sejam capturadas.
  • Prefira erros customizados para situações de domínio, mas use erros nativos quando fizer sentido.

Referências

Exercícios

  1. Crie uma classe DatabaseError que estenda Error e inclua uma propriedade código. Em seguida, lance um erro dessa classe em um bloco try e capture-o, exibindo o código e a mensagem.
  2. ✓ Resposta:
    class DatabaseError extends Error {
      constructor(mensagem, codigo) {
        super(mensagem);
        this.name = "DatabaseError";
        this.codigo = codigo;
      }
    }
    
    try {
      throw new DatabaseError("Falha na conexão", 500);
    } catch (erro) {
      if (erro instanceof DatabaseError) {
        console.log(`Código: ${erro.codigo} - Mensagem: ${erro.message}`);
      }
    }
    
  3. Escreva uma função parseJSON que receba uma string e retorne o objeto parseado. Se a string for inválida, capture o erro e relance um novo erro com a mensagem "JSON inválido" e a causa original.
  4. ✓ Resposta:
    function parseJSON(texto) {
      try {
        return JSON.parse(texto);
      } catch (erro) {
        throw new Error("JSON inválido", { cause: erro });
      }
    }
    
    try {
      parseJSON("{");
    } catch (erro) {
      console.error(erro.message); // JSON inválido
      console.error(erro.cause); // SyntaxError original
    }
    
  5. Crie uma função assíncrona obterDados que simula uma requisição que pode falhar (use setTimeout e Math.random). Use try/catch para tratar o erro e exibir uma mensagem amigável.
  6. ✓ Resposta:
    async function obterDados() {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if (Math.random() > 0.5) {
            resolve("Dados obtidos");
          } else {
            reject(new Error("Falha na rede"));
          }
        }, 1000);
      });
    }
    
    (async () => {
      try {
        const dados = await obterDados();
        console.log(dados);
      } catch (erro) {
        console.error("Erro ao obter dados:", erro.message);
      }
    })();
    
  7. Implemente um wrapper safe que recebe uma função e retorna uma nova função que nunca lança exceções, mas retorna um objeto com success e data ou error.
  8. ✓ Resposta:
    function safe(fn) {
      return (...args) => {
        try {
          const data = fn(...args);
          return { success: true, data };
        } catch (erro) {
          return { success: false, error: erro };
        }
      };
    }
    
    const dividirSeguro = safe((a, b) => {
      if (b === 0) throw new Error("Divisão por zero");
      return a / b;
    });
    
    const resultado = dividirSeguro(10, 0);
    if (resultado.success) {
      console.log(resultado.data);
    } else {
      console.error(resultado.error.message);
    }
    
  9. Explique a diferença entre usar Promise.catch() e try/catch com async/await e dê um exemplo de situação onde um é preferível ao outro.
  10. ✓ Resposta:

    Promise.catch() é usado quando você tem uma cadeia de Promises e deseja tratar erros de qualquer parte da cadeia de forma encadeada. try/catch com async/await é mais legível para fluxos assíncronos sequenciais, pois parece código síncrono. Prefira async/await quando houver múltiplas operações assíncronas que dependem umas das outras; use .catch() para transformações simples ou quando você quer manter o encadeamento.