Sealed Classes + Switch: o compilador como auditor do seu domínio
Introdução
Quantas vezes você adicionou uma classe nova no sistema e esqueceu de atualizar um switch perdido em algum serviço?
No Java antigo, o default engolia esse caso novo sem avisar ninguém. O código compilava, os testes passavam, e o bug só aparecia em produção.
Com o Java 21, isso muda. Duas funcionalidades trabalhando juntas fazem o compilador apontar exatamente onde você esqueceu de tratar o caso novo:
- Sealed Classes (JEP 409), finalizada no Java 17
- Pattern Matching for Switch (JEP 441), finalizada no Java 21
O problema: o default que esconde bugs
Imagine um sistema de pagamentos no estilo antigo, com uma interface comum e uma cadeia de instanceof para decidir o que fazer:
String descrever(Pagamento p) {
if (p instanceof Pix pix) {
return "Pix para " + pix.chave();
} else if (p instanceof Cartao c) {
return "Cartão " + c.bandeira();
} else {
return "Pagamento desconhecido"; // o "default"
}
}
Amanhã alguém cria a classe Cashback implements Pagamento. O que acontece?
Nada. O código compila. O Cashback cai no else e o cliente vê "Pagamento desconhecido" na tela. Ninguém é avisado.
O compilador não reclama porque ele não sabe quantas implementações de Pagamento existem. Qualquer classe, em qualquer lugar, pode implementar essa interface.
Sealed: uma hierarquia fechada
Uma interface sealed resolve exatamente isso. Ela diz ao compilador: "só estas classes podem me implementar, e mais nenhuma".
public sealed interface Pagamento permits Pix, Cartao, Boleto {}
public record Pix(String chave, BigDecimal valor) implements Pagamento {}
public record Cartao(String bandeira, int parcelas, BigDecimal valor) implements Pagamento {}
public record Boleto(LocalDate vencimento, BigDecimal valor) implements Pagamento {}
Agora a lista é conhecida em tempo de compilação: Pix, Cartao e Boleto. Se alguém tentar criar outra classe que implementa Pagamento sem estar no permits, o código não compila.
Records são
finalpor padrão, por isso combinam tão bem comsealed. Cada implementação já nasce fechada.
Switch exaustivo, sem default
Como o compilador conhece todas as opções, ele consegue conferir se o seu switch tratou cada uma delas:
String descrever(Pagamento p) {
return switch (p) {
case Pix pix -> "Pix para " + pix.chave();
case Cartao c -> "Cartão %s em %dx".formatted(c.bandeira(), c.parcelas());
case Boleto b -> "Boleto vence em " + b.vencimento();
};
}
Repare: não tem default. E não precisa ter. O switch é exaustivo, ou seja, cobre 100% dos casos possíveis.
Se você esquecer um caso, o código nem compila:
String descrever(Pagamento p) {
return switch (p) {
case Pix pix -> "Pix para " + pix.chave();
case Cartao c -> "Cartão " + c.bandeira();
// esqueceu o Boleto
};
}
error: the switch expression does not cover all possible input values
O domínio evoluiu: entrou o Cashback
Aqui está o ganho real. O time de produto pediu um novo meio de pagamento. Você adiciona no permits:
public sealed interface Pagamento permits Pix, Cartao, Boleto, Cashback {}
public record Cashback(BigDecimal saldoUsado, BigDecimal valor) implements Pagamento {}
Ao compilar, todo switch que processa Pagamento quebra o build. O compilador te entrega a lista exata de lugares que precisam tratar o Cashback: o serviço de descrição, o cálculo de taxa, a conciliação, o relatório.
Nenhum deles fica para trás. Nenhum deles vai para produção sem tratamento.
Compare com o modelo antigo: lá, o erro aparecia em runtime, na mão do cliente. Aqui, aparece em tempo de compilação, na mão do desenvolvedor.
Cuidado: o default desliga a proteção
Esse é o erro mais comum de quem começa a usar sealed. Por hábito, a pessoa coloca um default no final:
// ✗ Evite
return switch (p) {
case Pix pix -> "Pix para " + pix.chave();
case Cartao c -> "Cartão " + c.bandeira();
default -> "Outro pagamento";
};
Esse código compila. Mas quando o Cashback entrar, ele cai no default em silêncio, e você volta ao problema do início.
A regra é simples: com hierarquia sealed, não use default. Trate cada tipo de forma explícita e deixe o compilador trabalhar por você.
Condições extras com when
Às vezes o tipo sozinho não basta. Um cartão parcelado em mais de 12 vezes pode ter uma regra diferente. Para isso existe o when:
BigDecimal taxa(Pagamento p) {
return switch (p) {
case Pix pix -> BigDecimal.ZERO;
case Cartao c when c.parcelas() > 12 -> new BigDecimal("0.05");
case Cartao c -> new BigDecimal("0.02");
case Boleto b -> new BigDecimal("2.50");
};
}
Atenção à ordem: o caso mais específico (when) vem antes do mais geral. E repare que o case Cartao c sem condição continua obrigatório. Sem ele, o compilador reclama, porque um cartão com até 12 parcelas não estaria coberto.
Desmontando o record direto no case
Se você só precisa de alguns campos, dá para usar Record Patterns e extrair os valores direto no case:
String resumo(Pagamento p) {
return switch (p) {
case Pix(var chave, var valor) -> "Pix de " + valor + " para " + chave;
case Cartao(var bandeira, var x, var v) -> bandeira + " em " + x + "x";
case Boleto(var vencimento, var valor) -> "Boleto de " + valor + " até " + vencimento;
};
}
A exaustividade continua valendo. Menos código, e o compilador ainda confere tudo.
E o null?
Um switch com pattern matching lança NullPointerException se receber null, como sempre fez. Se null for um valor esperado no seu fluxo, trate de forma explícita:
return switch (p) {
case null -> "Sem pagamento";
case Pix pix -> "Pix para " + pix.chave();
case Cartao c -> "Cartão " + c.bandeira();
case Boleto b -> "Boleto vence em " + b.vencimento();
};
Onde isso se encaixa: Programação Orientada a Dados
Esse estilo tem nome: Programação Orientada a Dados (DOP, do inglês Data-Oriented Programming). A ideia é separar bem duas coisas:
- Os dados: records imutáveis, simples, sem regra de negócio dentro.
- As operações: funções que recebem esses dados e decidem o que fazer com um
switch.
A interface sealed define o formato possível dos dados. O switch exaustivo garante que toda operação trata todos os formatos. Juntos, eles transformam uma exceção esquecida em runtime em um erro de compilação.
| Java antigo | Java 21 com sealed + switch | |
|---|---|---|
| Novo tipo no sistema | Cai no default em silêncio |
Build quebra |
| Onde o erro aparece | Em produção | Na sua máquina |
| Quem avisa | O cliente | O compilador |
Precisa de default? |
Sim | Não |
Conclusão
Sealed Classes sozinhas fecham a hierarquia. Pattern Matching sozinho deixa o switch mais legível. Mas é juntando os dois que o ganho aparece: o compilador passa a conhecer todos os casos do seu domínio e cobra que você trate cada um deles.
Na próxima vez que for escrever um default em um switch sobre um tipo sealed, pare e pergunte: eu quero mesmo que o compilador pare de me avisar?
Este artigo faz parte da série Sealed e Hidden Classes do getcaramelo.dev.
Tem dúvidas ou quer aprofundar algum ponto? Me encontra no LinkedIn ou no GitHub.
Gostou do conteúdo? Me segue no @getcaramelo.dev
Caramelo