Sealed classes e when no Kotlin

1. Visão geral

Quando um valor pode cair em apenas alguns poucos casos fixos, por exemplo, um resultado de pagamento é bem-sucedido, falhou ou pendente, você quer que o compilador saiba bem essa lista. Kotlin fornece sealed classes e sealed interfaces para travar a hierarquia de herança. A expressão when pode verificar se você já tratou todos os casos possíveis.

Depois de ler este artigo, você saberá como declarar uma hierarquia sealed e escrever um when exhaustivo. Você também aprenderá a usar guard conditions, escrever testes para essa lógica e reconhecer quando não deve usar sealed classes.

2. O problema com hierarquias de herança abertas

Tente modelar um resultado de pagamento usando uma abstract class comum:

abstract class PaymentResult

class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()

fun describe(result: PaymentResult): String = when (result) {
    is Success -> "OK: ${result.transactionId}"
    is Failed -> "Erro: ${result.reason}"
    else -> "Desconhecido"
}

O branch else é obrigatório. O motivo é que qualquer pessoa, em qualquer módulo, pode criar subclasses adicionais de PaymentResult. O compilador não pode saber se a lista de subclasses está completa ou não.

A consequência é que quando alguém adiciona class Refunded : PaymentResult(), o código ainda compila normalmente. Um valor Refunded silenciosamente cai no branch "Desconhecido". Esse tipo de erro geralmente só é descoberto em produção.

enum class resolve parte do problema porque a lista de constantes é fixa. No entanto, cada constante de enum é uma única instância. Você não pode anexar transactionId diferentes para cada pagamento bem-sucedido.

3. Declarando sealed classes

Sealed classes combinam as vantagens de ambas as abordagens. A lista de subclasses é fixa como em um enum, e cada subclasse ainda pode carregar seus próprios dados como uma classe comum.

3.1. Dependência

O exemplo neste artigo usa Kotlin 2.2 na JVM 21. Os testes usam JUnit 5 e AssertJ:

<dependencies>
    <dependency>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-stdlib</artifactId>
        <version>2.2.20</version>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.27.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Você também precisa configurar kotlin-maven-plugin na mesma versão para compilar o código-fonte Kotlin.

3.2. Uma hierarquia sealed simples

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
}

Cada parte do código acima tem o seguinte significado:

  • sealed class PaymentResult é uma classe abstrata implícita. Você não pode chamar PaymentResult() diretamente. Seu construtor é protected por padrão.
  • Success e Failed são data classes. Você já tem equals, hashCode, toString e copy, muito convenientes para escrever testes.
  • Pending não carrega dados, então use data object. É um singleton e seu toString() imprime Pending em vez de PaymentResult$Pending@1b6d3586.

A regra mais importante: toda subclasse direta deve estar no mesmo pacote e no mesmo módulo que a sealed class. Você pode dividi-las em vários arquivos no mesmo pacote. No entanto, outro módulo usando sua biblioteca não conseguirá adicionar novas subclasses. É graças a essa regra que o compilador sabe com certeza a lista de subclasses.

4. Expressões when exhaustivas

4.1. Removendo o branch else

Quando o assunto do when é do tipo sealed, você não precisa de else mais:

fun describe(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Pagou ${result.amount} VND (código ${result.transactionId})"
    is PaymentResult.Failed -> "Falha: ${result.reason}"
    PaymentResult.Pending -> "Pendente"
}

fun main() {
    println(describe(PaymentResult.Success("TX-1001", 250_000)))
    println(describe(PaymentResult.Failed("Cartão expirado", retryable = false)))
    println(describe(PaymentResult.Pending))
}

Resultado quando executado:

Pagou 250000 VND (código TX-1001)
Falha: Cartão expirado
Pendente

Há três pontos importantes a observar no código acima:

  • Smart cast: no branch is PaymentResult.Success, a variável result é automaticamente convertida para Success. Portanto, você acessa result.amount sem precisar de um cast manual.
  • Com object, você compara diretamente usando PaymentResult.Pending sem precisar de is. Escrever is PaymentResult.Pending também é correto, mas não é necessário.
  • when aqui é uma expressão, ou seja, retorna um valor e é atribuída como corpo da função. Uma expressão when deve ser obrigatoriamente exhaustiva.

4.2. O compilador pega erros ao adicionar um novo caso

Suponha que você adicione um status de reembolso:

sealed class PaymentResult {
    data class Success(val transactionId: String, val amount: Long) : PaymentResult()
    data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
    data object Pending : PaymentResult()
    data class Refunded(val transactionId: String) : PaymentResult()
}

A função describe leva imediatamente a um erro de compilação:

e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.

Este é exatamente o valor essencial de sealed classes. Todo lugar no código que usa when em PaymentResult é apontado pelo compilador. Você não pode esquecer nenhum lugar.

4.3. when como uma instrução

A partir de Kotlin 1.7, mesmo quando when é usado como uma instrução (sem retornar um valor), o compilador ainda reporta um erro se um branch estiver faltando. A condição é que o assunto tenha um tipo sealed, enum ou Boolean:

fun log(result: PaymentResult) {
    when (result) {
        is PaymentResult.Success -> println("INFO pagamento ${result.transactionId}")
        is PaymentResult.Failed -> println("WARN ${result.reason}")
        PaymentResult.Pending -> println("DEBUG pendente")
        is PaymentResult.Refunded -> println("INFO reembolso ${result.transactionId}")
    }
}

Se você omitir o branch Refunded, você recebe um erro de compilação e não apenas um aviso como em versões antigas de Kotlin.

5. Sealed interface

Uma classe pode herdar de apenas uma classe pai, mas pode implementar várias interfaces. Portanto, sealed interface é mais flexível quando um tipo pertence a vários grupos ao mesmo tempo:

sealed interface OrderEvent
sealed interface Notifiable {
    val customerEmail: String
}

data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent

fun handle(event: OrderEvent): String = when (event) {
    is OrderPlaced -> "Criar fatura para ${event.orderId}"
    is OrderShipped -> "Número de rastreamento ${event.trackingCode}"
    is StockReserved -> "Reservar estoque ${event.sku}"
}

fun emailOf(target: Notifiable): String = when (target) {
    is OrderPlaced -> "Enviar confirmação de pedido para ${target.customerEmail}"
    is OrderShipped -> "Enviar notificação de envio para ${target.customerEmail}"
}

OrderPlaced é tanto OrderEvent quanto Notifiable. A função emailOf precisa de apenas dois branches, porque o compilador sabe que Notifiable tem exatamente duas classes implementando.

Sealed interfaces também funcionam bem com enums. Uma enum class pode implementar uma sealed interface, e então cada constante do enum é contada como um branch no when.

5.1. Aninhamento em vários níveis

Uma subclasse também pode ser sealed. Assim você pode agrupar com mais detalhes:

sealed interface PaymentError {
    sealed interface Retryable : PaymentError
    data object Timeout : Retryable
    data object GatewayBusy : Retryable
    data class CardDeclined(val code: String) : PaymentError
}

fun shouldRetry(error: PaymentError): Boolean = when (error) {
    is PaymentError.Retryable -> true
    is PaymentError.CardDeclined -> false
}

O branch is PaymentError.Retryable já cobre tanto Timeout quanto GatewayBusy, então when ainda é exhaustivo com apenas dois branches.

6. Condições de guarda no when

Às vezes, mesmo tipo mas você quer lidar diferentemente dependendo dos dados dentro. Kotlin 2.2 suporta oficialmente guard conditions (palavra-chave if após a condição do branch):

fun nextAction(result: PaymentResult): String = when (result) {
    is PaymentResult.Success -> "Enviar fatura"
    is PaymentResult.Failed if result.retryable -> "Tentar novamente em 30 segundos"
    is PaymentResult.Failed -> "Relatar erro ao cliente"
    PaymentResult.Pending -> "Aguardar webhook do gateway"
    is PaymentResult.Refunded -> "Atualizar livro-razão"
}

Um branch com guard corresponde apenas se tanto o tipo quanto a condição if forem verdadeiros. O compilador não trata um branch com guard como se cobrisse Failed. Portanto, se você não tiver o branch is PaymentResult.Failed sem guard, ainda receberá um erro exhaustivo.

Nota: Em Kotlin 2.1, guard conditions ainda era uma feature em preview e precisava da flag -Xwhen-guards. Verifique a versão de Kotlin do seu projeto antes de usar.

7. Combinando em um serviço

No código real, sealed classes geralmente são o tipo de retorno de um serviço em vez de lançar exceções:

data class Order(val id: String, val amount: Long)

class PaymentService {
    fun charge(order: Order): PaymentResult = when {
        order.amount <= 0 -> PaymentResult.Failed("Valor inválido", retryable = false)
        order.amount > 50_000_000 -> PaymentResult.Pending
        else -> PaymentResult.Success("TX-${order.id}", order.amount)
    }
}

fun main() {
    val service = PaymentService()
    when (val result = service.charge(Order("A42", 120_000))) {
        is PaymentResult.Success -> println("Código da transação: ${result.transactionId}")
        else -> println(describe(result))
    }
}
Código da transação: TX-A42

Na função charge, when não tem subject, então precisa de else. Aqui else é totalmente apropriado porque as condições são expressões booleanas arbitrárias.

Em main, a sintaxe when (val result = ...) tanto chama o serviço quanto limita o escopo da variável result ao bloco when. O branch else aqui é intencional: você só se importa com o caso bem-sucedido. A seção 9 explicará o custo dessa escolha.

8. Escrevendo testes

Graças às data classes, você compara resultados usando isEqualTo de forma muito concisa:

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class PaymentServiceTest {

    private val service = PaymentService()

    @Test
    fun `pedido válido retorna Success`() {
        val result = service.charge(Order("A1", 99_000))

        assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
    }

    @Test
    fun `valor negativo retorna Failed sem retry`() {
        val result = service.charge(Order("A2", -5))

        assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
        assertThat((result as PaymentResult.Failed).retryable).isFalse()
    }

    @Test
    fun `pedido grande transiciona para Pending`() {
        assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
    }

    @Test
    fun `erro retentável leva à ação de retry`() {
        val action = nextAction(PaymentResult.Failed("Gateway timeout", retryable = true))

        assertThat(action).isEqualTo("Tentar novamente em 30 segundos")
    }
}

Este conjunto de testes verifica três pontos:

  • O primeiro teste depende de equals gerado por data class para comparar todos os dados.
  • isSameAs é usado para data object, porque Pending é um singleton, então a comparação por referência é suficiente.
  • O último teste verifica o branch com guard condition.

9. Erros comuns e quando não usar

Adicionar else ao when em tipos sealed. Este é o erro mais comum. Um branch else “seguro” desabilita completamente o mecanismo de verificação exhaustiva. Quando você adiciona uma nova subclasse, o compilador fica em silêncio. Use else apenas quando você realmente quer agrupar todos os casos restantes, como no exemplo da seção 7.

Usar when sem subject. when { result is Success -> ... } não é verificado como exhaustivo de acordo com a hierarquia sealed. Escreva when (result) para que o compilador possa analisar.

Esquecer data para object. Um object Pending geralmente ainda funciona. No entanto, o log imprimirá o nome da classe junto com o hash code, dificultando a leitura durante a depuração.

Colocar subclasses fora do módulo. Se você está escrevendo uma biblioteca e quer que os usuários a estendam (por exemplo, plugins), sealed class é a escolha errada. Use interface ou abstract class normais.

Nota: Não use sealed classes quando:

  • Os casos são apenas constantes sem dados, como LOW, MEDIUM, HIGH. Neste caso, enum class é mais conciso e tem entries e valueOf.
  • A lista de subclasses muda muito frequentemente e é contribuída por vários times em vários módulos.
  • O comportamento deveria estar no próprio objeto (polimorfismo através de method override) em vez de espalhado em when por todo o lado. Se o mesmo when se repete em dez lugares, considere mover a lógica para um método em cada subclasse.

10. Conclusão

Sealed classes e sealed interfaces ajudam você a travar a hierarquia de herança em um módulo. Assim, o compilador sabe completamente todos os casos. Combinado com when com subject, você obtém smart cast, não precisa de else e recebe um erro de compilação imediatamente quando esquece de tratar um novo estado.

Use data class para casos que carregam dados e data object para casos que não carregam. Escolha sealed interface quando precisar de um tipo que pertence a vários grupos. Guard conditions do Kotlin 2.2 ajudam a separar branches por dados internos. O mais importante é evitar branches else desnecessários, porque eles roubam exatamente o benefício que você está procurando.

kotlinsealed classwhenpattern matchingtype safety