Error handling em TypeScript: Result types e typed errors

Error handling em TypeScript: Result types e typed errors

Lidar com erros em aplicações é uma tarefa desafiadora e muitas vezes negligenciada por desenvolvedores. Em vez de apenas lançar exceções, é fundamental considerar como os erros são tratados e propagados através do código. Isso se torna ainda mais importante em projetos grandes, onde a clareza e a robustez do tratamento de erros podem fazer a diferença entre uma aplicação estável e uma cheia de bugs. Aqui, vou mostrar como os tipos de resultados (Result<T, E>) e erros tipados podem melhorar a forma como lidamos com falhas em TypeScript.

Throw vs Return

A abordagem tradicional para lidar com erros em JavaScript e TypeScript é o uso de throw. Lançar uma exceção interrompe a execução normal do código e transfere o controle para o primeiro bloco catch disponível. Embora isso funcione, pode se tornar problemático, especialmente em funções que exigem um fluxo de execução contínuo. Aqui está um exemplo simples:

function dividir(a: number, b: number): number {
    if (b === 0) {
        throw new Error("Divisão por zero");
    }
    return a / b;
}

try {
    console.log(dividir(10, 0));
} catch (error) {
    console.error(error.message);
}

Nesse caso, se b for zero, o código lança uma exceção e interrompe a execução. Embora isso funcione, pode ocultar o resultado real da operação e complicar as sequências de chamadas.

Uma alternativa mais funcional é retornar um tipo que encapsula o resultado e o erro. Isso traz clareza ao fluxo de execução e permite que a função retorne um resultado válido ou um erro sem interromper a execução.

Result<T, E> Pattern

O padrão Result<T, E> é uma maneira de encapsular resultados e erros em um único objeto. Ele permite que você trate erros como parte do fluxo normal do seu programa. Um exemplo básico de implementação desse padrão poderia ser:

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

function dividirComResultado(a: number, b: number): Result<number, string> {
    if (b === 0) {
        return { ok: false, error: "Divisão por zero" };
    }
    return { ok: true, value: a / b };
}

const resultado = dividirComResultado(10, 0);

if (resultado.ok) {
    console.log(`Resultado: ${resultado.value}`);
} else {
    console.error(`Erro: ${resultado.error}`);
}

Com essa abordagem, a função dividirComResultado retorna um objeto que indica se a operação foi bem-sucedida ou não. Isso torna o tratamento de erros mais explícito e facilita a manutenção do código.

Neverthrow Lib

Uma biblioteca popular que implementa esse padrão é a neverthrow. Ela fornece uma estrutura robusta para trabalhar com tipos de resultado e erros de forma tipada. Aqui está um exemplo de como usá-la:

import { err, ok, Result } from 'neverthrow';

function dividirComNeverthrow(a: number, b: number): Result<number, string> {
    if (b === 0) {
        return err("Divisão por zero");
    }
    return ok(a / b);
}

const resultado = dividirComNeverthrow(10, 0);

resultado.match({
    ok: (value) => console.log(`Resultado: ${value}`),
    err: (error) => console.error(`Erro: ${error}`),
});

A biblioteca neverthrow permite que você trate resultados e erros de forma mais fluida e expressiva. O método match é uma maneira elegante de lidar com os dois casos em um único lugar.

Custom Error Classes

Embora o uso de tipos de resultado seja uma ótima maneira de lidar com erros, também é importante ter classes de erro personalizadas para situações específicas. Isso torna mais fácil identificar e tratar diferentes tipos de erros em seu código. Aqui está um exemplo de como criar uma classe de erro personalizada:

class DivisaoPorZeroError extends Error {
    constructor() {
        super("Divisão por zero");
        this.name = "DivisaoPorZeroError";
    }
}

function dividirComErroPersonalizado(a: number, b: number): number {
    if (b === 0) {
        throw new DivisaoPorZeroError();
    }
    return a / b;
}

try {
    console.log(dividirComErroPersonalizado(10, 0));
} catch (error) {
    if (error instanceof DivisaoPorZeroError) {
        console.error(error.message);
    } else {
        console.error("Erro desconhecido");
    }
}

Com essa abordagem, você pode capturar e lidar com erros específicos, tornando seu código mais robusto e fácil de manter.

Zod Error Formatting

Quando lidamos com validação de dados, como em APIs, é comum usar bibliotecas como o Zod para definir esquemas e validar objetos. O Zod também fornece uma maneira de formatar erros de validação de forma clara e estruturada. Veja um exemplo de como usar o Zod para validação e tratamento de erros:

import { z } from 'zod';

const schema = z.object({
    nome: z.string().min(1),
    idade: z.number().min(0),
});

function validarUsuario(usuario: unknown) {
    try {
        schema.parse(usuario);
    } catch (error) {
        if (error instanceof z.ZodError) {
            console.error(error.errors);
        } else {
            console.error("Erro desconhecido na validação");
        }
    }
}

validarUsuario({ nome: "", idade: -1 });

Neste caso, se o usuário não atender aos requisitos do esquema, o Zod lançará um ZodError, que pode ser tratado de forma específica.

API Error Boundaries

Em aplicações que consomem APIs, é crucial ter um tratamento de erros bem definido. Ao fazer chamadas a APIs, muitas vezes você enfrentará erros de rede, respostas inesperadas ou erros de validação. Uma abordagem comum é criar limites de erro (error boundaries) que capturam esses erros e fornecem uma resposta apropriada.

Aqui está um exemplo de como implementar isso:

async function chamarApi(url: string): Promise<Result<any, string>> {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            return err(`Erro na API: ${response.statusText}`);
        }
        const data = await response.json();
        return ok(data);
    } catch (error) {
        return err(`Erro de rede: ${error.message}`);
    }
}

chamarApi('https://api.exemplo.com/dados')
    .then(resultado => {
        resultado.match({
            ok: dados => console.log(`Dados recebidos: ${JSON.stringify(dados)}`),
            err: erro => console.error(`Erro: ${erro}`),
        });
    });

Com isso, você encapsula o tratamento de erros de API, permitindo que seu código lide com falhas de forma mais eficiente.

Logging

Por fim, não podemos esquecer da importância do logging ao lidar com erros. É fundamental registrar informações sobre erros para que você possa diagnosticar problemas posteriormente. Um bom sistema de logging deve registrar detalhes sobre o erro, incluindo a pilha de chamadas, dados relevantes e o contexto em que ocorreu.

Aqui está um exemplo simples de como você poderia implementar um logging básico:

function registrarErro(error: Error) {
    console.error(`Erro: ${error.message}`);
    // Aqui você poderia enviar o erro para um serviço de logging externo
}

try {
    dividirComErroPersonalizado(10, 0);
} catch (error) {
    registrarErro(error);
}

Com um logging eficaz, você pode melhorar a manutenção e a depuração do seu código, tornando mais fácil resolver problemas quando eles surgem.

Conclusão

Tratar erros de forma eficiente é crucial para a criação de aplicações robustas e confiáveis. Ao adotar padrões como Result<T, E>, usar bibliotecas como neverthrow, criar classes de erro personalizadas e formatar erros com Zod, você pode melhorar significativamente a maneira como sua aplicação lida com falhas. Além disso, implementar limites de erro em APIs e um sistema de logging eficaz ajudará a aumentar a resiliência do seu código.

Takeaways Práticos

  • Utilize o padrão Result<T, E> para encapsular resultados e erros.
  • Considere usar a biblioteca neverthrow para simplificar o tratamento de erros.
  • Crie classes de erro personalizadas para lidar com situações específicas.
  • Use o Zod para validação de dados e formatação de erros.
  • Implemente limites de erro em chamadas a APIs e registre os erros para facilitar a depuração.

Glossário do post

typescript
Superset tipado do JavaScript criado pela Microsoft que adiciona tipos estáticos, ajudando a pegar erros antes de rodar o código.
javascript
Linguagem de programação que roda no navegador (e em servidores via Node.js), responsável pela interatividade das páginas web.
api
Application Programming Interface: conjunto de regras que permite a um programa conversar com outro, expondo dados e funcionalidades de forma padronizada.
Ismael Douglas

Sobre o autor

Ismael Douglas

Desenvolvedor Full Stack apaixonado por construir produtos web rápidos e bem projetados. Escrevo sobre o que aprendo no caminho — código, infra e boas práticas.

Veja meus projetos no portfólio →