Pular para o conteúdo

Trilha de Kotlin · parte 17 de 59

Tratamento funcional de erros

Aula da fase 3 da trilha de Kotlin: Quando `Result` e `runCatching` ajudam, quando `Either` do Arrow vale o custo, e quando uma exceção continua sendo a escolha certa?

intermediárioPublicada em · 12 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Toda função que pode falhar precisa comunicar a falha de algum jeito. O Java usa exceções, e as verificadas (as checked exceptions) obrigam quem chama a declarar ou tratar o erro na assinatura do método. O Go devolve um valor de erro junto com o resultado, e o chamador precisa olhar para ele. O Kotlin não tem exceções verificadas: nenhuma exceção aparece na assinatura, e o compilador não cobra o tratamento. Isso deixa o código mais curto, mas também esconde a falha de quem lê a assinatura. Esta aula trata das alternativas que a própria linguagem oferece, Result e um tipo próprio com sealed, e de quando a exceção continua sendo a escolha certa, principalmente quando há transação de banco no meio do caminho. Arrow aparece no fim como uma alternativa citada, não como dependência.

Uma comparação para guardar: exceção é como um alarme de incêndio. Ele toca em qualquer andar, interrompe quem estiver fazendo qualquer coisa e leva todo mundo para a saída, e isso é exatamente o que você quer quando há fogo. Um valor de erro é como um formulário de devolução: o cliente recebe o pacote, lê o motivo e decide o que fazer, sem que ninguém precise correr. Os dois são úteis. O erro de projeto é usar o alarme para coisas que deveriam ser formulários, como um produto fora de estoque.

o mapa da aula

Começamos pelo que muda quando a linguagem não obriga o tratamento de erro. Depois vemos o Result da biblioteca padrão, com a saída real de cada operação, incluindo a armadilha do aninhamento que aparece quando se encadeia uma operação que também devolve Result. Em seguida, um tipo próprio com sealed, que coloca o caso de erro na assinatura e faz o when exigir o tratamento. A parte final trata das exceções: quando elas continuam certas, por que uma transação de banco depende delas, e onde a biblioteca Arrow se encaixa. Todo código foi compilado com o kotlinc 2.1.21.

1. o que muda sem exceção verificada

Quando um método Java declara throws IOException, a assinatura avisa que a chamada pode falhar, e o compilador exige que o chamador trate a falha ou repasse a declaração. Isso tem um custo, porque obriga a repetir throws por toda a cadeia de chamadas, e muitos programadores acabam escrevendo catch vazio só para calar o compilador. O Kotlin optou por não ter essa obrigação. Qualquer função pode lançar qualquer exceção sem declará-la.

fun leArquivo(): String = throw java.io.IOException("sem disco")

fun main() {
    try {
        leArquivo()
    } catch (e: java.io.IOException) {
        println("capturada: ${e.message}")
    }
}

A saída é capturada: sem disco. O código compila, mesmo sem o throws na assinatura de leArquivo. Esse é o ponto que a aula quer destacar: a assinatura da função não diz que ela pode falhar. Para uma leitora ou um leitor de código, a informação sobre falha precisa ser procurada no corpo ou na documentação. Por isso o Kotlin oferece alternativas que colocam o erro no tipo de retorno, e a escolha entre elas é de projeto.

Há um detalhe de interoperação: a anotação @Throws existe para informar o Java sobre exceções lançadas por uma função Kotlin. Ela não muda o comportamento em Kotlin, e não será usada aqui.

2. Result: o erro como valor

A biblioteca padrão do Kotlin tem o tipo Result<T>, disponível desde a versão 1.3, que representa ou um valor de sucesso ou uma exceção de falha. A função runCatching executa um bloco e devolve esse Result, capturando qualquer exceção. A consulta à documentação oficial, feita em 2026-10-20, descreve Result como uma união discriminada e lista as funções de extensão, como map, fold, getOrElse e recover.

Veja uma função de domínio que lança exceção por dentro e um wrapper que devolve Result por fora:

class SaldoInsuficiente(mensagem: String) : Exception(mensagem)

fun debitar(saldo: Int, valor: Int): Int {
    if (valor > saldo) throw SaldoInsuficiente("saldo $saldo, pedido $valor")
    return saldo - valor
}

fun debitarResult(saldo: Int, valor: Int): Result<Int> = runCatching { debitar(saldo, valor) }

fun main() {
    println(debitarResult(10, 3))
    println(debitarResult(10, 30))
    println(debitarResult(10, 3).getOrNull())
    println(debitarResult(10, 30).exceptionOrNull()?.message)
}

A saída real é:

Success(7)
Failure(SaldoInsuficiente: saldo 10, pedido 30)
7
saldo 10, pedido 30

O chamador agora precisa lidar com os dois casos de forma explícita, usando fold para transformar cada caminho em um resultado final:

val mensagem = debitarResult(10, 30).fold(
    onSuccess = { "novo saldo $it" },
    onFailure = { "recusado: ${it.message}" },
)
println(mensagem)

A saída é recusado: saldo 10, pedido 30. A assinatura de debitarResult já diz que a função pode falhar, o que é a vantagem central de tirar o erro do canal de exceção.

Um fato que surpreende quem vem de Java é como Result aparece no bytecode. Compilando uma função fun carregar(): Result<Int>, o javap mostra que o retorno é java.lang.Object. Quem chama a função em Java não vê o tipo Result, e precisa tratar o valor como objeto genérico. Por isso Result é um bom tipo para a lógica interna de um serviço, e uma escolha mais cuidadosa para uma API pública consumida por Java.

3. a armadilha do aninhamento

Quando a operação seguinte também devolve Result, o encadeamento com map produz um Result dentro de outro. Veja o caso:

val aninhado = debitarResult(10, 3).map { debitarResult(it, 5) }
println(aninhado)

A saída é Success(Success(2)). O primeiro saldo, 10, pagou 3 e ficou com 7, e o segundo débito, de 5, também deu certo e deixou 2. O tipo resultante é Result<Result<Int>>, e o código que usa aninhado precisa desembrulhar duas vezes.

A biblioteca padrão não tem flatMap para Result, e tentar usá-lo falha na compilação. A mensagem real do kotlinc é unresolved reference 'flatMap'.. A função map espera uma transformação que devolva um valor comum, e não outro Result. Quem se acostuma com flatMap em outras bibliotecas tende a escrever o código errado sem perceber, porque a compilação passa com um tipo aninhado. A saída é o sinal de alerta: se aparecer Success(Success(...)), o encadeamento precisa ser revisto.

Esse limite é uma razão para que equipes que encadeiam muitas operações de falha prefiram um tipo próprio, ou uma biblioteca que ofereça a composição completa, como a Arrow descrita na seção 6.

4. runCatching captura tudo, inclusive o que não deveria

Um detalhe que costuma passar despercebido é o alcance de runCatching. Ele captura Throwable, a raiz de toda a hierarquia de exceções e erros do JVM, e não apenas Exception. Isso inclui erros como AssertionError e OutOfMemoryError, além de exceções de controle de fluxo, como o CancellationException das coroutines. A saída real de um teste simples mostra o alcance:

val erro = runCatching { throw AssertionError("simulado") }
println("falhou: ${erro.isFailure}, tipo: ${erro.exceptionOrNull()?.javaClass?.simpleName}")

val cancel = runCatching { throw java.util.concurrent.CancellationException("parar") }
println("isFailure: ${cancel.isFailure}")

A saída é falhou: true, tipo: AssertionError e depois isFailure: true. Nos dois casos o runCatching engoliu o que deveria ter subido. Em código de coroutines, engolir CancellationException quebra o cancelamento, que é um tema da fase 4. A regra prática é usar runCatching para falhas de negócio conhecidas, e não como um catch genérico de tudo.

5. um tipo próprio com sealed

Quando a falha é parte do domínio, como saldo insuficiente, muitas equipes preferem um tipo próprio. Ele deixa explícitos os casos possíveis e usa o when para forçar o tratamento. O exemplo a seguir modela o débito com uma interface selada:

sealed interface Debito {
    data class Feito(val novoSaldo: Int) : Debito
    data object SemSaldo : Debito
}

fun debitarTipado(saldo: Int, valor: Int): Debito =
    if (valor > saldo) Debito.SemSaldo else Debito.Feito(saldo - valor)

fun main() {
    listOf(debitarTipado(10, 3), debitarTipado(10, 30)).forEach { d ->
        val texto = when (d) {
            is Debito.Feito -> "saldo ${d.novoSaldo}"
            Debito.SemSaldo -> "sem saldo"
        }
        println(texto)
    }
}

A saída é saldo 7 e sem saldo. A forma de cada caso aparece no tipo, e o compilador exige que um when sobre Debito cubra todas as possibilidades. Quando removemos o ramo SemSaldo de um when como esse, a compilação falha com 'when' expression must be exhaustive. Add the 'SemSaldo' branch or an 'else' branch.. A mensagem sugere um else, mas o melhor é cobrir o caso explicitamente, porque o else esconde casos novos que forem adicionados depois.

A figura abaixo organiza a escolha entre os canais de erro com base em duas perguntas.

Como escolher o canal de erro. Função que pode falhar → A falha faz parte do contrato?; A falha faz parte do contrato? → Retorno tipado (sim)…
figura 1 · Como escolher o canal de erro

A figura mostra o caminho de decisão. A primeira pergunta é se a falha faz parte do contrato da função, como um saldo insuficiente que o usuário precisa ver. Se sim, o retorno tipado é a escolha, e o caminho verde leva a esse tipo. Se não, a falha é de infraestrutura ou de bug, e o caminho cinza leva à exceção. A aula não usa mais de duas perguntas porque a decisão real depende de outros fatores, como a transação, que a próxima seção trata.

Pergunta de entrevistaPor que runCatching pode esconder bugs?

runCatching captura qualquer Throwable, inclusive erros de programação, como NullPointerException, e devolve um Result. Se o bloco chama código com defeito, o erro vira um Failure silencioso, fácil de ignorar. Uso Result para falhas esperadas, como um valor inválido vindo do usuário, e deixo a exceção para o que não deveria acontecer. Em coroutines, também evito capturar CancellationException, porque isso quebra o cancelamento.

6. quando a exceção continua certa

A parte mais importante desta aula é que Result e tipos selados não substituem exceções em todos os casos. Falha de infraestrutura, como um banco fora do ar ou um timeout de rede, normalmente não tem uma ação útil no ponto da chamada. O código que chamou a função não sabe o que fazer além de propagar o erro até um ponto que possa registrá-lo, responder ao cliente ou tentar de novo. Nesse caso, a exceção é o mecanismo que faz a informação subir pela pilha sem exigir que cada camada a repasse manualmente.

Há um caso específico que pesa muito em serviços Spring. Quando um método é anotado com @Transactional, o framework decide se confirma ou desfaz a transação olhando para a exceção que sai do método. Pelo padrão do Spring, uma exceção de tempo de execução faz o rollback (e Error também), enquanto uma exceção verificada não faz. Um Result com falha devolvido normalmente também não faz rollback. Se o serviço engole o erro e devolve Result.failure, a transação pode ser confirmada com dados parciais. A figura mostra esse caminho.

Result engole a exceção e o commit acontece. Serviço @Transactional → passo 1; passo 1 → passo 2; passo 2 → Exceção (lança); passo 2 → Result.failure (retorna)…
figura 2 · Result engole a exceção e o commit acontece

A figura acompanha o mesmo serviço em dois desfechos. No caminho da exceção, a falha sobe até o proxy da transação, que faz o rollback. No caminho do Result, o erro vira um valor de retorno, o proxy não vê nada de errado e confirma o commit, e o passo 1 fica gravado sem o passo 2. A seta vermelha final indica esse dano.

Esse comportamento do Spring vem da documentação do framework e de sua configuração padrão. Ele não foi reproduzido nesta sessão, porque exige o framework e um banco. Confirme na versão do projeto antes de confiar nele para decidir o desenho.

7. Arrow como alternativa citada

A biblioteca Arrow oferece um tipo Either, com um lado de erro e um lado de sucesso, e um DSL para encadear operações que podem falhar. Ela resolve o problema do aninhamento da seção 3 e oferece uma composição mais completa do que Result. Como não foi usada nem verificada nesta aula, nenhum trecho dela aparece aqui. A recomendação é avaliar a biblioteca quando o projeto já a utiliza ou quando o volume de operações encadeadas justificar a dependência, consultando a documentação oficial da versão que o projeto adota.

O ponto para não esquecer é que nenhuma biblioteca dessas dispensa a decisão de projeto da seção 6. Mesmo com Arrow, a falha de infraestrutura continua sendo mais simples de tratar como exceção, e o rollback continua dependendo de como o erro sai do método.

8. erros comuns e boas práticas

O primeiro erro é transformar tudo em Result por uniformidade. Isso esconde falhas de infraestrutura em um valor que ninguém verifica, e quebra o rollback de transações. A boa prática é usar o retorno tipado para falhas de domínio e deixar a infraestrutura lançar exceções.

O segundo erro é usar runCatching como catch genérico. Como ele captura Throwable, pode engolir cancelamentos e erros graves do JVM. A boa prática é envolver apenas a operação que sabe falhar e tratar o tipo de exceção esperado.

O terceiro erro é encadear map com funções que devolvem Result e não perceber o aninhamento. O sinal é a saída Success(Success(...)). A correção é reorganizar o fluxo, usar fold ou um tipo próprio.

O quarto erro é usar when com else sobre um tipo selado. O else silencia o compilador quando um caso novo é adicionado, e a verificação de exaustividade deixa de proteger o código.

9. exercícios

  1. Escreva uma função buscarUsuario(id: Int): Usuario? e outra que devolve Result<Usuario>. Discuta, por escrito, qual assinatura comunica melhor a possibilidade de falha.
  2. Crie um sealed interface Pagamento com os casos Aprovado, Recusado(motivo: String) e Pendente. Escreva uma função que converte o pagamento em texto com when sem else, e depois adicione um caso novo para ver o erro do compilador.
  3. Escreva um encadeamento de três operações que devolvem Result e observe a saída do aninhamento. Reescreva usando fold e compare a legibilidade.

10. glossário

Exceção verificada: exceção que o compilador obriga a declarar ou tratar. Existe no Java e não existe no Kotlin.

Result: tipo da biblioteca padrão que representa sucesso ou falha, como valor.

Sealed: classe ou interface cujos subtipos são conhecidos e fechados no mesmo módulo, o que permite ao compilador verificar a exaustividade de um when.

Rollback: desfazer as alterações de uma transação de banco quando ela falha.

Either: tipo de biblioteca, como o da Arrow, com um lado de erro e outro de sucesso.

fontes

A documentação oficial da classe Result foi consultada em 2026-10-20 em https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-result/, que descreve a versão 2.4 da documentação e lista as funções de extensão, sem flatMap. Os exemplos foram compilados e executados com o kotlinc 2.1.21 instalado junto ao IntelliJ IDEA nesta máquina, também em 2026-10-20. O comportamento de rollback do Spring e a API da Arrow não foram verificados nesta sessão e estão descritos de forma geral.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?