Result Types

- 15 min read

Erros

Erros são, infelizmente, inevitáveis.
Não importa o quão limpo seja o seu código, situações inesperadas — como falhas em chamadas de API, timeouts de banco de dados ou arquivos ausentes, quebra de contratos, erros no banco e etc etc etc — irão ocorrer.
Mas aqui está o problema:
O tratamento inadequado de erros não causa apenas uma falha, os riscos podem ser maiores, visto que o tratamento inadequado de erros pode levar a uma experiência ruim do usuário, riscos de segurança e problemas de produção difíceis de depurar e ajustar.

Por que o tratamento de erros é importante?

  • Aumenta a confiabilidade: Impede que o aplicativo trave devido a erros não tratados.
  • Aprimora a experiência do usuário: Os usuários veem mensagens de erro significativas em vez de rastreamentos de pilha brutos.
  • Evita a depuração: Logs bem estruturados ajudam a identificar a causa raiz rapidamente.
  • Aumenta a segurança: Evita a exposição de informações confidenciais do servidor.

Várias formas de lidar com erros são possíveis, sendo que a mais comum é a de lançamento de exceções. Algumas formas tomei como base do  Nodejs Best Practices e algumas são nativas de alguns runtimes (oi, Rust!). Hoje, explicitamente, venho escrever um pouco sobre uma das que mais tem me chamado atenção nos últimos anos e comparar com a abordagem de gestão de exceções mais utilizada.

Try-Catch

Atualmente a forma mais comum e amplamente utilizada nas diversas especificações de linguagens e runtimes é a estrutura de try-catch, para tratar exceções. Funcionando como uma espécie de go-to, este modelo busca interceder sobre erros conhecidos e impedir que eles quebrem a aplicação.

try...catch é uma estrutura de controle em programação usada para tratar erros (exceções) que podem acontecer em um bloco de código, evitando que o programa pare abruptamente; o bloco try contém o código a ser monitorado, e o catch executa um tratamento específico se uma exceção ocorrer dentro do try, com um finally opcional para código que sempre será executado.

Entretanto, este modelo não vem sem desvantagens. Algumas das mais conhecidas são:

Problemas de Qualidade e Manutenibilidade do Código

  • Código Desorganizado e Legibilidade Reduzida: Envolver código em excesso em blocos try-catch, especialmente dentro de cada função, torna a lógica de negócios principal mais difícil de acompanhar e entender, aumentando a carga cognitiva para os desenvolvedores.
  • Mascaramento de Erros: Uma má prática comum é capturar uma exceção genérica e não fazer nada (um bloco catch vazio) ou simplesmente registrá-la sem relançá-la ou tratá-la adequadamente. Isso oculta bugs subjacentes e torna a depuração extremamente difícil, pois o programa continua como se nada tivesse acontecido, apesar de estar em um estado instável.
  • Fluxo de Controle Oculto: Exceções podem criar um efeito "goto invisível", dificultando a previsão da origem dos erros e como o fluxo de execução do programa será alterado, o que quebra a separação de responsabilidades. Além disso, o rastreamento de pilha completo pode ser perdido quando uma exceção é capturada e não relançada corretamente, dificultando a identificação da origem exata de um erro. O uso de um único bloco try-catch grande em torno de várias operações agrava esse problema. Fora isso, existe um apelo comum em utilizar Exceções para Controle de Fluxo, apesar de já sabermos que empregar exceções para lidar com eventos esperados e não excepcionais (como validação de entrada ou verificação da existência de um arquivo) é um antipadrão. Idealmente, as condições esperadas devem ser gerenciadas com instruções condicionais if/else ou verificações de validação.

Considerações de Desempenho

  • Sobrecarga de Desempenho: Embora o impacto no desempenho ao entrar em um bloco try seja frequentemente insignificante em compiladores modernos (especialmente se nenhuma exceção for lançada), o ato de lançar e capturar uma exceção é computacionalmente custoso. Essa sobrecarga é significativa porque envolve a coleta de dados de rastreamento de pilha e o desenrolamento da pilha de chamadas.
  • Interferência de Otimização: A presença de blocos try-catch pode, às vezes, dificultar as otimizações do compilador, como a inlining de métodos, resultando potencialmente em um código ligeiramente mais lento.

Problemas de Design e Escopo

  • Variáveis ​​definidas dentro do bloco try podem não ser acessíveis fora dele, forçando os desenvolvedores a declarar variáveis ​​em um escopo mais amplo, o que pode levar a um código menos limpo.
  • Capturar exceções genéricas (por exemplo, Exception ou Error) pode inadvertidamente capturar erros ou exceções não relacionados que deveriam ser tratados em um nível superior, levando a comportamentos inesperados.

E qual a solução?

Monads

Lembro quando uns dois anos atrás um amigo de equipe me perguntou o que eram promises e por quê elas eram importantes. Eu respondi que promises nada mais eram que monads e justamente por isso funcionavam tão bem para o que faziam. Obviamente, meu amigo me perguntou: “certo, mas e o que são monads”?

A monad is just a monoid

Uma mônada é apenas um monóide na categoria de endofuntores

Mônadas/Monads são um conceito fundamental vindo da programação funcional, funcionam como "invólucros" (wrappers) que permitem encadear operações e gerenciar efeitos colaterais (como I/O, valores nulos, assincronicidade) de forma elegante, utilizando funções puras em cadeias de código legível, sem matemática complexa, através de funções como of (ou return) e flatMap (ou bind) que gerenciam a lógica vinda destes wrappers. Elas abstraem complexidades, como lidar com erros ou estado, tornando o código mais limpo e modular, com exemplos como Maybe (para nulos), Promise/Future (para assincronicidade) e Results (para estado). Então como isto funciona para Promises e por quê é interessante quando lidamos com erros?

Promises são monads que lidam com dois outputs principais, encapsulando o estado de resolve (que quer dizer que a operação ocorreu) e rejected (que quer dizer que a operação não funcionou corretamente). Isto para ambientes assíncronos é fundamental para que quando uma operação ocorra, o resultado seja processado por alguma operação dentro daquele pequeno universo em cadeia.
Por exemplo, uma chamada a uma API de tempo poderia ter o seguinte formato:


fetch('https://api.weather.com')
.then(res => res.json())
.then(res => console.log(res.temperature))
.catch(err => console.error(error))

Neste modelo, vemos funções sendo aplicadas ao retorno da função inicial, nos permitindo lidar com casos de erro assim que ocorram e mapear casos de sucesso de maneira consecutiva. Isto é interessante, porque podemos focar na linearidade de casos de sucesso, já que os métodos then não serão executados quando houver um erro. De mesmo modo, podemos recuperar erros ou identificá-los com o método catch, sem nos preocupar com algo que não seja o próprio erro.

Então, para nós, promises são uma maneira muito eficiente de lidar com cenários de sucesso ou erro, pois carregam dois valores que podem ser mapeados com funções encadeadas, sem que saibamos de antemão o resultado.

E como isso ajudaria a lidar com erros?

Fluxos Throw-catch geralmente borbulham erros que podem (ou não) ser capturados em algum ponto. Claro, isto é uma grande vantagem para erros que têm um catch global ou uma estrutura mais generalizada, entretanto, quando temos erros pouco conhecidos ou regras de negócio mais complexas, esta mesma estrutura esconde exceções que fazem parte do escopo e que precisam ser lidadas de maneira mais clara. Imagine o seguinte cenário de negócio:
Um vendedor nunca pode ter um estoque negativo, portanto, sempre que um item é vendido devemos impedir um estoque negativo e notificar o vendedor sobre o estoque atual. Simples, certo?
Geralmente em um universo Java, faríamos algo como:

public void sellItem(String itemId, int quantity) {
    Inventory inventory = inventoryService.getInventory(itemId);

    if (inventory.getStock() - quantity < 0) {
        // Lança uma exceção para indicar o erro de regra de negócio
        throw new NegativeStockException("Não é possível vender, estoque ficaria negativo.");
    }

    // Lógica de venda
    inventory.setStock(inventory.getStock() - quantity);
    inventoryService.updateInventory(inventory);
}

// Em algum outro lugar, o código que chama sellItem deve lidar com a exceção
public void processSale(String itemId, int quantity) {
    try {
       sellItem(itemId, quantity);
notificationService.notifySeller(
        "Venda realizada com sucesso. Novo estoque de " + itemId + ": " + inventory.getStock()
    );
        System.out.println("Venda processada com sucesso.");
    } catch (NegativeStockException e) {
        // Captura e lida com o erro de estoque negativo
        System.err.println("Erro na venda: " + e.getMessage());
 notificationService.notifySeller(
        "Venda não foi realizada com sucesso. Novo estoque de " + itemId + ": " + inventory.getStock()
    );
        // Lógica adicional de tratamento de erro (ex: logar, compensar)
    } catch (Exception e) {
        // Captura outros erros inesperados
        System.err.println("Ocorreu um erro inesperado: " + e.getMessage());
    }
}

Apesar de termos uma estrutura que PRECISA lidar com exceções, isto não é verdade para outras linguagens, como JavaScript, Python, PHP, Ruby e afins. Além disso, precisamos saber qual exceção será lançada e ter pontos de contorno para ela. De certo modo, é como se furássemos o escopo local para que a responsabilidade fosse tratada em outro local, o que pode gerar confusão e perda de entendimento.

Neste cenário, podemos fazer uso de Result Types.

Na programação funcional, um tipo de resultado é um tipo monádico que armazena um valor retornado ou um código de erro. Eles fornecem uma maneira elegante de lidar com erros, sem recorrer ao tratamento de exceções; quando uma função que pode falhar retorna um tipo de resultado, o programador é forçado a considerar caminhos de sucesso ou falha antes de obter o resultado esperado; isso elimina a possibilidade de uma suposição errônea por parte do programador. (Wikipedia)

Imaginemos então como lidar com este cenário, utilizando results. Como Java não tem como parte da lib padrão o tipo Result, vamos implementar um bem simples apenas para fins didáticos.

public class Result<T, E> {
    private final T value;
    private final E error;
    private final boolean isSuccess;

    private Result(T value, E error, boolean isSuccess) {
        this.value = value;
        this.error = error;
        this.isSuccess = isSuccess;
    }

    public static <T, E> Result<T, E> success(T value) {
        return new Result<>(value, null, true);
    }

    public static <T, E> Result<T, E> failure(E error) {
        return new Result<>(null, error, false);
    }

    public boolean isSuccess() {
        return isSuccess;
    }

    public boolean isFailure() {
        return !isSuccess;
    }

    public T getValue() {
        if (isFailure()) {
            throw new IllegalStateException("Tentativa de obter valor de um Result em estado de falha.");
        }
        return value;
    }

    public E getError() {
        if (isSuccess()) {
            throw new IllegalStateException("Tentativa de obter erro de um Result em estado de sucesso.");
        }
        return error;
    }

    // Função de transformação (como o 'map' ou 'then' das Promises)
    public <U> Result<U, E> map(Function<T, U> mapper) {
        if (isSuccess) {
            return Result.success(mapper.apply(value));
        } else {
            return Result.failure(error);
        }
    }

    // Função de encadeamento (como o 'flatMap' ou 'bind')
    public <U> Result<U, E> flatMap(Function<T, Result<U, E>> mapper) {
        if (isSuccess) {
            return mapper.apply(value);
        } else {
            return Result.failure(error);
        }
    }

    // Helper para lidar com o sucesso ou falha
    public <R> R fold(Function<E, R> onFailure, Function<T, R> onSuccess) {
        if (isSuccess) {
            return onSuccess.apply(value);
        } else {
            return onFailure.apply(error);
        }
    }
}

Agora, com o nosso Result simples, podemos reescrever a lógica de negócio do exemplo do estoque negativo, definindo também o tipo de erro que queremos representar:

public class NegativeStockError {
    private final String message;
    private final String itemId;
    private final int currentStock;
    
    public NegativeStockError(String itemId, int currentStock) {
        this.message = "Não é possível vender. O estoque ficaria negativo.";
        this.itemId = itemId;
        this.currentStock = currentStock;
    }

    public String getMessage() {
        return message + " (Item: " + itemId + ", Estoque Atual: " + currentStock + ")";
    }
}

E no nosso código central, faríamos o seguinte:

public Result<void,NegativeStockError> sellItem(String itemId, int quantity) {
    Inventory inventory = inventoryService.getInventory(itemId);

    if (inventory.getStock() - quantity < 0) {
        // Lança uma exceção para indicar o erro de regra de negócio
        return Result.failure(new NegativeStockError(itemId, inventory.getStock()));
    }

    // Lógica de venda
    inventory.setStock(inventory.getStock() - quantity);
    inventoryService.updateInventory(inventory);
}

// Em algum outro lugar, o código que chama sellItem deve lidar com o resultado
public void processSale(String itemId, int quantity) {
   sellItem(itemId, quantity).fold(() -> {
notificationService.notifySeller(
        "Venda realizada com sucesso. Novo estoque de " + itemId + ": " + inventory.getStock()
    );
        System.out.println("Venda processada com sucesso.");
}, (err) -> {
 notificationService.notifySeller(
        "Venda não realizada, um erro ocorreu: " + err.message
    );
        System.out.println(err.message);

});

}

À primeira vista, parece que injetamos mais complexidade e um novo tipo para gerir. Embora seja verdade, também ganhamos uma interface única que nos obriga a lidar com estes erros - salientando também que poucas linguagens modernas nos obrigam a lidar com o erro de antemão. Em JavaScript/Typescript, por exemplo, qualquer tipo pode ser lançado em um throw, o que dificulta muito a rastreabilidade e manutenção de exceções. Por isso, todos estes trade-offs podem e devem ser analisados de acordo com as necessidades de cada projeto.
Entretanto, é muito comum que projetos de grande porte vão tendo regimes excepcionais cada vez mais complexos, o que demanda uma racionalização muito mais aprofundada sobre estes temas - e é aqui que Result Types podem ser um grande ganho.

Ao lidar com um código TypeScript, por exemplo, lançar exceções deve ser considerado uma espécie de "último recurso" — reservado para casos especiais raros, quando a conveniência de escapar para o próximo bloco try-catch supera a insegurança de tipos e a a tipagem implícita. Na maioria dos casos, em vez de lançar exceções, podemos aproveitar a explicitude do TypeScript e simplesmente retornar erros — e existe uma longa tradição de tratar erros como cidadãos "retornáveis" de primeira classe em linguagens de programação. O neverthrow traz isso para o TypeScript - sendo esta uma das libs que mais tenho gostado de usar. Qualquer falha da qual a função não possa se recuperar torna-se um erro ou uma união discriminada de resultados de erro. Isso é coloquialmente denotado no mundo do código seguro como um Result\<T, E\> — onde T é o resultado do caminho feliz e E é o erro tipado, como criamos no cenário acima.

Desta maneira, código que sabemos que podemos nos recuperar como lógica de negócio, reconexão de providers, falha de banco de dados, leitura de arquivos e afins, pode muito bem ser atacado antes de uma exceção catastrófica. Para isso, geralmente gosto de identificar exceções recuperáveis e não-recuperáveis, exceções de programador e exceções operacionais (Sugestão de Leitura). Assim, o processo de gestão de catástrofes fica muito mais óbvio e simples de racionalizar.
Também é interessante notar que em uma base de código com tipos de retorno do tipo Result em todos os níveis, não apenas nos forçamos a lidar com erros antecipadamente, como também desbloqueamos alguns padrões de programação funcional interessantes, como os implementados acima (fold, map). Com uma API neste formato, podemos aplicar conceitos de programação funcional com muita clareza e termos, como no caso do neverthrow, uma tipagem de retorno segura, um exemplo bem legal nesta mesma lib é converter erros para formatos HTTP:

// Exemplo de como usar a lib neverthrow para lidar com erros e mapeá-los para HTTP

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

// Definindo tipos de Erro de Negócio
export class NotFoundError {
    readonly type = 'NotFoundError';
    constructor(readonly message: string) {}
}
export class InvalidInputError {
    readonly type = 'InvalidInputError';
    constructor(readonly message: string) {}
}
export class DatabaseAccessError {
    readonly type = 'DatabaseAccessError';
    constructor(readonly message: string) {}
}

// Tipo de União para todos os Erros de Negócio possíveis

export type BusinessError = NotFoundError | InvalidInputError | DatabaseAccessError;
|---|
// Simulação de um serviço que pode falhar
function getUserById(id: string): Result<{ id: string; name: string }, BusinessError> {
    if (id === '123') {
        return ok({ id: '123', name: 'Alice' });
    }
    if (id === '999') {
        // Simula um erro de recurso não encontrado
        return err(new NotFoundError(`Usuário com ID ${id} não encontrado.`));
    }
    if (!id || id.length < 5) {
        // Simula um erro de entrada inválida
        return err(new InvalidInputError('ID do usuário deve ter pelo menos 5 caracteres.'));
    }
    // Caso padrão de falha, simulando um erro de DB
    return err(new DatabaseAccessError('Falha ao conectar ao banco de dados.'));
}

// Função para mapear o Result para uma resposta HTTP (simulada)
interface HttpResponse {
    statusCode: number;
    body: any;
}

function mapResultToHttpResponse(
    result: Result<{ id: string; name: string }, BusinessError>
): HttpResponse {
    return result.match(
        // Caso de Sucesso
        (user) => ({
            statusCode: 200,
            body: user,
        }),
        // Caso de Falha
        (error) => {
            switch (error.type) {
                case 'NotFoundError':
                    return {
                        statusCode: 404,
                        body: { error: 'Not Found', message: error.message },
                    };
                case 'InvalidInputError':
                    return {
                        statusCode: 400,
                        body: { error: 'Bad Request', message: error.message },
                    };
                case 'DatabaseAccessError':
                default:
                    // Erros inesperados ou internos (recuperáveis ou não)
                    return {
                        statusCode: 500,
                        body: { error: 'Internal Server Error', message: 'Ocorreu um erro interno.' },
                    };
            }
        }
    );
}

// Exemplos de uso
const result1 = getUserById('123');
const httpResponse1 = mapResultToHttpResponse(result1);
console.log('Resultado 1 (Sucesso):', httpResponse1);
// Output: { statusCode: 200, body: { id: '123', name: 'Alice' } }

const result2 = getUserById('999');
const httpResponse2 = mapResultToHttpResponse(result2);
console.log('Resultado 2 (Não Encontrado):', httpResponse2);
// Output: { statusCode: 404, body: { error: 'Not Found', message: 'Usuário com ID 999 não encontrado.' } }

const result3 = getUserById('abc');
const httpResponse3 = mapResultToHttpResponse(result3);
console.log('Resultado 3 (Input Inválido):', httpResponse3);
// Output: { statusCode: 400, body: { error: 'Bad Request', message: 'ID do usuário deve ter pelo menos 5 caracteres.' } }

const result4 = getUserById('valid_id_but_db_fails'); // Simulando falha de DB
const httpResponse4 = mapResultToHttpResponse(result4);
console.log('Resultado 4 (Falha Interna):', httpResponse4);
// Output: { statusCode: 500, body: { error: 'Internal Server Error', message: 'Ocorreu um erro interno.' } }

Conclusão

Abordagens de uso de Result Types variam de runtime para runtime, mas estes conceitos podem ajudar (e muito!) em implementações mais complexas. Por mais que seja uma forma diferente de visualizar a gestão de erros, é um tópico que vale muito a pena de ser entendido e estudado mais a fundo, por mais que muitas vezes pareça algo mais verboso ou mais complexo. Tudo isso vem de trade-offs importantes para o desenvolvedor e o time que devem ser levados em consideração antes de empregar este padrão.

Refs:
https://medium.com/rung-brasil/ent%C3%A3o-voc%C3%AA-ainda-n%C3%A3o-entende-monads-1ea62e0c14a7
https://www.solberg.is/neverthrow
https://github.com/supermacro/neverthrow?tab=readme-ov-file#recommended-use-eslint-plugin-neverthrow
Então você ainda não entende monads
Mastering Error Handling in Node.js: Best Practices for Robust Applications


Gabriel Berthier

Author: Gabriel Berthier