Sealed classes e pattern matching para switch em Java 21

1. Visão Geral

Quando um tipo de dados tem apenas algumas variantes fixas, como pagamento com cartão, transferência bancária ou carteira digital, geralmente os processamos com uma série de if (x instanceof ...). Essa abordagem é verbosa. Pior ainda, é fácil esquecer um caso sem que ninguém perceba.

Java 21 resolve esse problema com três funcionalidades que trabalham juntas: sealed classes (oficial desde Java 17), pattern matching para switch (JEP 441) e record patterns (JEP 440). Depois de ler este artigo, você saberá como modelar um conjunto fechado de tipos, escrever um switch cujo compilador verifica automaticamente se todos os casos estão cobertos, e evitar erros comuns.

2. O problema com a abordagem antiga

Suponha que você precisa calcular a taxa para cada tipo de pagamento. Antes do Java 21, o código geralmente se parecia com isto:

public long fee(Object payment) {
    if (payment instanceof CardPayment c) {
        return c.amount() * 2 / 100;
    } else if (payment instanceof BankTransfer b) {
        return 5_500;
    }
    throw new IllegalArgumentException("Não suportado: " + payment);
}

Esse código tem três problemas:

  • Você esqueceu de EWallet, mas o código compila normalmente. O erro só é descoberto em tempo de execução.
  • O ramo throw final é obrigatório, embora não devesse acontecer do ponto de vista do negócio.
  • Quando você adiciona um novo tipo de pagamento, precisa encontrar manualmente todos os lugares com essa série de if para fazer a correção.

3. Sealed classes

3.1. Declarando uma sealed interface

Uma sealed class ou sealed interface lista exatamente quais classes têm permissão para herdá-la:

public sealed interface Payment permits CardPayment, BankTransfer, EWallet {
    long amount();
}

public record CardPayment(String cardNumber, long amount) implements Payment {}

public record BankTransfer(String bankCode, long amount) implements Payment {}

public record EWallet(String provider, long amount) implements Payment {}

A palavra-chave sealed junto com a cláusula permits diz ao compilador que Payment tem exatamente três subtipos. Se outra classe, como class Crypto implements Payment, tentar herdar, você receberá um erro de compilação.

Se as classes filhas estão no mesmo arquivo que a classe pai, você pode omitir permits. O compilador vai deduzir a lista automaticamente.

3.2. Regras para classes filhas

Cada classe filha direta de um tipo sealed deve declarar um dos três modificadores:

Modificador Significado
final Ninguém pode herdar dela
sealed Continua limitando com seu próprio permits
non-sealed Aberta para qualquer classe herdar

Records são final por padrão, então o exemplo acima não precisa de nada mais. Com classes comuns, você deve especificar:

public sealed abstract class Notification permits EmailNotification, SmsNotification {}

public final class EmailNotification extends Notification {}

public non-sealed class SmsNotification extends Notification {}

// Válido porque SmsNotification é non-sealed
public class ZaloNotification extends SmsNotification {}

Aqui EmailNotification é completamente bloqueada. Por outro lado, SmsNotification está aberta para qualquer um herdar. Como resultado, ao fazer switch em Notification, o ramo SmsNotification cobrirá também ZaloNotification.

3.3. Restrições de pacote e módulo

As classes em permits devem estar no mesmo módulo que a classe pai. Se seu projeto não usa módulos (unnamed module), elas devem estar no mesmo pacote. Isto é por design: o autor da classe pai controla toda a hierarquia.

4. Pattern matching para switch

4.1. Type pattern

A partir do Java 21, rótulos case podem ser um padrão de tipo com uma variável:

public class FeeCalculator {

    public long fee(Payment payment) {
        return switch (payment) {
            case CardPayment c -> c.amount() * 2 / 100;
            case BankTransfer b -> 5_500;
            case EWallet w -> 0;
        };
    }
}

Cada case tanto verifica o tipo quanto atribui uma variável já com cast feito (c, b, w). A variável só existe no escopo de seu ramo. O código não tem default e não tem throw.

4.2. Exhaustiveness: o compilador verifica se todos os casos estão cobertos

Como Payment é sealed, o compilador sabe exatamente quais são os três subtipos. Se você remover a linha case EWallet w -> 0;, o código não compilará:

FeeCalculator.java:5: error: the switch expression does not cover all possible input values
        return switch (payment) {
               ^

Este é o maior benefício. Quando você adiciona QrPayment ao permits, todo switch em Payment em sua base de código reportará um erro até ser atualizado. O compilador age como uma lista de tarefas.

Observação: Se as classes forem compiladas separadamente e em tempo de execução um subtipo desconhecido aparecer, a JVM lançará MatchException. O compilador insere automaticamente esse ramo para você.

4.3. Guard com when

Você pode adicionar uma condição a um padrão usando when:

public long fee(Payment payment) {
    return switch (payment) {
        case CardPayment c when c.amount() >= 10_000_000 -> c.amount() * 1 / 100;
        case CardPayment c -> c.amount() * 2 / 100;
        case BankTransfer b -> 5_500;
        case EWallet w -> 0;
    };
}

Transações de cartão a partir de 10 milhões de dong cobram 1% de taxa, outras cobram 2%. Um ramo com guard não é considerado como cobrindo completamente CardPayment. Portanto, o segundo ramo case CardPayment c ainda é obrigatório.

Teste com algumas transações:

public class Main {
    public static void main(String[] args) {
        var calculator = new FeeCalculator();
        List<Payment> payments = List.of(
                new CardPayment("4111-1111", 500_000),
                new CardPayment("4111-2222", 20_000_000),
                new BankTransfer("VCB", 1_000_000),
                new EWallet("momo", 200_000));

        payments.forEach(p -> System.out.println(p + " -> taxa " + calculator.fee(p)));
    }
}

Resultado:

CardPayment[cardNumber=4111-1111, amount=500000] -> taxa 10000
CardPayment[cardNumber=4111-2222, amount=20000000] -> taxa 200000
BankTransfer[bankCode=VCB, amount=1000000] -> taxa 5500
EWallet[provider=momo, amount=200000] -> taxa 0

4.4. Ordem de case e dominância

switch combina padrões de cima para baixo. Se um ramo mais geral ficar antes de um mais específico, o ramo posterior nunca será alcançado. O compilador detecta este erro:

return switch (payment) {
    case CardPayment c -> c.amount() * 2 / 100;
    case CardPayment c when c.amount() >= 10_000_000 -> c.amount() / 100; // erro
    case BankTransfer b -> 5_500;
    case EWallet w -> 0;
};
error: this case label is dominated by a preceding case label

Regra fácil de lembrar: coloque ramos específicos (com guard ou subtipo) antes, ramos gerais depois.

4.5. Tratando null

Por padrão, switch em null lança NullPointerException, assim como o comportamento antigo. Se quiser tratar null explicitamente, adicione case null:

public String label(Payment payment) {
    return switch (payment) {
        case null -> "Nenhum método selecionado";
        case CardPayment c -> "Cartão";
        case BankTransfer b -> "Transferência";
        case EWallet w -> "Carteira " + w.provider();
    };
}

Você também pode combinar case null, default -> quando switch tem um ramo default.

5. Record patterns

Record patterns permitem desconstruir os componentes de um record direto no case. Graças a isso, você não precisa chamar acessadores:

public String describe(Payment payment) {
    return switch (payment) {
        case CardPayment(String number, long amount) ->
                "Cartão ****" + number.substring(number.length() - 4) + ": " + amount;
        case BankTransfer(var bank, var amount) -> "Transferência " + bank + ": " + amount;
        case EWallet(var provider, var amount) -> "Carteira " + provider + ": " + amount;
    };
}

CardPayment(String number, long amount) combina quando payment é um CardPayment, e simultaneamente atribui number e amount. Você pode usar var e deixar o compilador inferir o tipo. Com new CardPayment("4111-1111", 500_000), o resultado é Cartão ****1111: 500000.

O verdadeiro poder dos record patterns reside na capacidade de aninhar:

public sealed interface Transaction permits Charge, Refund {}
public record Charge(Payment payment) implements Transaction {}
public record Refund(Payment original, String reason) implements Transaction {}

public String audit(Transaction tx) {
    return switch (tx) {
        case Charge(CardPayment(var number, var amount)) when amount >= 10_000_000 ->
                "Necessita aprovação: cartão " + number;
        case Charge(Payment p) -> "Cobrar " + p.amount();
        case Refund(var original, var reason) -> "Reembolsar " + original.amount() + " (" + reason + ")";
    };
}

O primeiro ramo combina um Charge contendo um CardPayment com valor alto, tudo em uma linha. O compilador ainda verifica exhaustiveness para padrões aninhados: Charge(Payment p) cobre todo Charge, e Refund(...) cobre todo Refund.

Observação: O padrão sem nome _, por exemplo case Refund(_, var reason), é apenas preview no Java 21 e requer --enable-preview. Este recurso é oficial a partir do Java 22. Se seu projeto se mantém no Java 21, use var com nomes de variável comuns.

6. Testes

Você precisa configurar Java 21 junto com JUnit 5 e AssertJ. Se usar Spring Boot 3.x, spring-boot-starter-test já inclui ambos. Com um projeto Maven puro:

<properties>
    <maven.compiler.release>21</maven.compiler.release>
</properties>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Teste para FeeCalculator na seção 4.3 e describe na seção 5:

class FeeCalculatorTest {

    private final FeeCalculator calculator = new FeeCalculator();

    @Test
    void cardPaymentBelowThreshold_charges2Percent() {
        assertThat(calculator.fee(new CardPayment("4111-1111", 500_000))).isEqualTo(10_000);
    }

    @Test
    void cardPaymentAboveThreshold_charges1Percent() {
        assertThat(calculator.fee(new CardPayment("4111-2222", 20_000_000))).isEqualTo(200_000);
    }

    @Test
    void bankTransferAndWallet_haveFixedFee() {
        assertThat(calculator.fee(new BankTransfer("VCB", 1_000_000))).isEqualTo(5_500);
        assertThat(calculator.fee(new EWallet("momo", 200_000))).isZero();
    }

    @Test
    void describe_masksCardNumber() {
        assertThat(calculator.describe(new CardPayment("4111-1111", 500_000)))
                .isEqualTo("Cartão ****1111: 500000")
                .doesNotContain("4111-");
    }
}

Os testes devem se concentrar nos limites dos guards (logo abaixo e acima de 10 milhões), pois é aí que a ordem de case é mais fácil de errar. Exhaustiveness você não precisa testar: o compilador já cuida disso.

7. Erros comuns e quando não usar

Adicionar default ao switch em um tipo sealed. O código ainda compila, mas você perde o principal benefício. Quando adicionar um subtipo novo, ele cai silenciosamente em default em vez de causar erro de compilação. Com tipos sealed, liste todos os ramos e omita default.

Esquecer que um ramo com when não cobre completamente o tipo. Se todo case CardPayment tem guard, o compilador reportará casos faltantes. Sempre precisa de um ramo sem guard, ou um ramo mais geral, no final.

Usar switch em vez de polimorfismo em toda parte. Se o comportamento realmente pertence ao próprio objeto, um método abstrato na classe pai ainda é melhor. Pattern matching é apropriado quando a lógica fica fora da hierarquia, como cálculo de taxa, renderização, mapeamento para DTO, e você não quer colocar tudo no record.

Observação: Não use sealed classes quando:

  • Você escreve uma biblioteca ou ponto de extensão (plugin) que usuários precisam estender com seus próprios subtipos.
  • A classe é uma entidade JPA que precisa de lazy loading. Hibernate cria proxies herdando da entidade, então sealed ou final bloqueará esse mecanismo.
  • Os subtipos se espalham por vários módulos. Sealed requer que estejam no mesmo módulo, ou no mesmo pacote se não usar módulos.

Serialização JSON. Jackson não sabe automaticamente como desserializar um Payment para o subtipo correto só porque a interface é sealed. Você ainda precisa configurar informações de tipo, como @JsonTypeInfo junto com @JsonSubTypes.

8. Conclusão

Sealed classes permitem declarar explicitamente que um tipo tem apenas um conjunto fixo de variantes. Pattern matching para switch e record patterns transformam esse conjunto em código conciso, com verificação automática de exhaustiveness pelo compilador. Você pode usar guard when para adicionar condições, case null para tratar null explicitamente, e padrões aninhados para ir fundo nos dados.

Três princípios para lembrar: omita default ao fazer switch em tipo sealed, coloque ramos específicos antes de gerais, e use sealed apenas para hierarquias que você realmente quer fechar.

javajava-21sealed-classespattern-matchingswitch