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 chamarPaymentResult()diretamente. Seu construtor éprotectedpor padrão.SuccesseFailedsãodata classes. Você já temequals,hashCode,toStringecopy, muito convenientes para escrever testes.Pendingnão carrega dados, então usedata object. É um singleton e seutoString()imprimePendingem vez dePaymentResult$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ávelresulté automaticamente convertida paraSuccess. Portanto, você acessaresult.amountsem precisar de um cast manual. - Com
object, você compara diretamente usandoPaymentResult.Pendingsem precisar deis. Escreveris PaymentResult.Pendingtambém é correto, mas não é necessário. whenaqui é uma expressão, ou seja, retorna um valor e é atribuída como corpo da função. Uma expressãowhendeve 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
equalsgerado pordata classpara comparar todos os dados. isSameAsé usado paradata object, porquePendingé 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 tementriesevalueOf.- 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
whenpor todo o lado. Se o mesmowhense 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.