Um Payment com três booleans — pending, cancelled, refunded — parece inofensivo até alguém instanciar new PaymentFlags(true, true, true). Isso compila, roda, e devolve um rótulo. O tipo nunca disse que essa combinação era proibida; ele só tinha três bits e nenhuma regra amarrando os três.
final class PaymentFlags {
boolean pending, cancelled, refunded;
PaymentFlags(boolean pending, boolean cancelled, boolean refunded) {
this.pending = pending; this.cancelled = cancelled; this.refunded = refunded;
}
String label() {
if (pending) return "pending";
if (cancelled) return "cancelled";
if (refunded) return "refunded";
return "unknown";
}
public static void main(String[] args) {
System.out.println(new PaymentFlags(true, true, true).label());
}
}
javac --release 25 -d out PaymentFlags.java
java -cp out PaymentFlags
Saída: pending. O primeiro if resolve o caso mais raro e esconde os outros dois. Funciona até funcionar.
Três booleans independentes representam 2^3 = 8 combinações possíveis. O domínio de pagamento tem quatro estados de verdade — pendente, pago, cancelado, reembolsado — não oito. As quatro combinações sobrando (pending+cancelled+refunded entre elas) não deveriam nem compilar. O resultado deste artigo é fazer exatamente isso: transformar "estado impossível" em erro de javac, não em bug relatado por um cliente.
Pré-requisitos
Os exemplos rodam em Eclipse Temurin 25.0.4+7 LTS, compilados com javac --release 25, sem --enable-preview. Nenhuma das features usadas é nova no JDK 25: sealed classes são finais desde o JDK 17 (JEP 409), e pattern matching para switch junto com record patterns são finais desde o JDK 21 (JEP 441, JEP 440). O JDK 25 entra aqui só por ser a LTS atual do host, não porque o artigo dependa de algo introduzido nela.
Modelando os estados válidos
Em vez de três booleans soltos, uma sealed interface que lista exatamente quem pode implementá-la, e um record por estado:
sealed interface PaymentState permits Pending, Paid, Cancelled, Refunded {}
record Pending() implements PaymentState {}
record Paid(long amountCents) implements PaymentState {
Paid {
if (amountCents < 0) throw new IllegalArgumentException("negative");
}
}
record Cancelled(String reason) implements PaymentState {}
record Refunded(long amountCents) implements PaymentState {}
permits fecha a lista de implementadores; cada um precisa ser final, sealed ou non-sealed, e records já são final por definição. O JEP 409 chama essa combinação de tipo algébrico: sealed é a soma (um dos quatro), record é o produto (os campos de cada um). Paid carrega amountCents, Cancelled carrega reason, e não existe — porque o tipo não permite — um valor que seja Pending e Cancelled ao mesmo tempo.
Verificado neste host: PaymentState.class.isSealed() retorna true, e PaymentState.class.getPermittedSubclasses().length retorna 4 (API de Class). Rodando javap -verbose PaymentState aparece um atributo PermittedSubclasses listando as quatro classes — não existe uma flag ACC_SEALED separada; o selamento é esse atributo.
Tentar estender por fora falha na compilação:
record Extra() implements PaymentState {}
Unauthorized.java:1: error: class is not allowed to extend sealed class: PaymentState (as it is not listed in its 'permits' clause)
Migração incremental sem quebrar quem lê os booleans antigos
Ninguém reescreve todo caller de isPending() num sprint só. O caminho que compila hoje é guardar um único PaymentState e derivar os booleans antigos dele — nunca os dois lado a lado:
final class Payment {
private final PaymentState state;
Payment(PaymentState state) { this.state = state; }
PaymentState state() { return state; }
boolean isPending() { return state instanceof Pending; }
boolean isCancelled() { return state instanceof Cancelled; }
boolean isRefunded() { return state instanceof Refunded; }
boolean isPaid() { return state instanceof Paid; }
String legacyLabel() {
if (isPending()) return "pending";
if (isCancelled()) return "cancelled";
if (isRefunded()) return "refunded";
if (isPaid()) return "paid";
return "unknown";
}
}
isPending() e isCancelled() nunca retornam true ao mesmo tempo, porque os dois consultam o mesmo campo state. Não há como desincronizar o que não existe em duplicata. Guardar os três booleans e o campo PaymentState reintroduz o problema original — os dois podem discordar. O código novo passa a fazer switch em state(); o código legado que ainda chama isPending() continua compilando sem saber que mudou nada por baixo.
Quando o boolean simples ainda é a escolha certa
Sealed modela um conjunto fechado de tipos; enum modela um conjunto fechado de instâncias; boolean modela um único fato de sim/não. Um flag como enabled, verbose ou uma soft-delete solitária não têm exclusividade mútua com nada — as combinações são todas legítimas. Selar um tipo para representar um bit isolado é cerimônia sem ganho de exaustividade sobre um if (enabled) comum.
O sinal de que vale migrar é o oposto: dois ou mais booleans que descrevem estágios do mesmo ciclo de vida, onde pelo menos uma combinação é proibida pelo domínio, não pelo tipo. Se todas as 2^n combinações forem válidas, fica boolean (ou um record de booleans, se quiser agrupar). E não declare um implementador non-sealed só para "deixar aberto depois" — isso reabre o branch e o switch volta a tratar aquele ramo como catch-all, escondendo subclasses desconhecidas exatamente onde você queria exaustividade.
Verificação: o switch exaustivo e o erro que você quer ver
Com PaymentState selado, um switch que cobre os quatro permits não precisa de default:
final class Labels {
static String label(PaymentState s) {
return switch (s) {
case Pending() -> "pending";
case Paid(long amount) -> "paid:" + amount;
case Cancelled(String reason) -> "cancelled:" + reason;
case Refunded(long amount) -> "refunded:" + amount;
};
}
}
Removendo o case Refunded de propósito:
SealedMissing.java:3: error: the switch expression does not cover all possible input values
return switch (s) {
^
Esse é o ganho: esquecer um estado vira erro de build, não comportamento silencioso em produção. E aqui mora a armadilha mais fácil de cometer sem perceber — adicionar default -> "other" junto com o case Refunded ausente faz o mesmo código compilar de novo. O default parece gentileza; na prática é o compilador prometendo cobrir um buraco que você nunca tapou. Se o objetivo é exaustividade, não escreva default sobre um seletor selado.
Duas verificações a mais, já cobertas pelo mesmo modelo:
label(null)lançaNullPointerException— umswitchde pattern matching semcase nullnão aceitanullimplicitamente.new Paid(-1)lançaIllegalArgumentException, vinda do construtor compacto do record — isso valida o valor de um estado específico, não a exaustividade entre estados; são preocupações diferentes e cada uma mora no lugar certo.
Rodando o harness completo:
javac --release 25 -d out PaymentState.java Payment.java Labels.java
java -cp out Labels
true
4
paid:100
1
NEG:negative
legacy:refunded
both:false
NPE
Cada linha confirma um fato: isSealed() é true, quatro subtipos permitidos, o switch decompõe Paid(100) em paid:100, o acessor amountCents() devolve 1, o construtor compacto rejeita -1, o adaptador legado (Payment) devolve legacy:refunded, isPending() && isCancelled() é false, e label(null) lança a exceção em vez de devolver um rótulo qualquer.
Observação em produção
A correção executável já está acima: o par PaymentState + Payment como única fonte de verdade, com o switch exaustivo barrando estado novo não tratado. Para observabilidade, troque logs e métricas que hoje registram três booleans separados por um único campo tirado do tipo:
log.info("payment_state={}", payment.state().getClass().getSimpleName());
Isso garante que o dashboard sempre recebe um valor de um conjunto fechado e conhecido (Pending, Paid, Cancelled ou Refunded), nunca uma combinação de bits que ninguém previu ao montar o alerta.
Recomendação
A DevDojo adota sealed interface + records assim que um tipo de domínio junta dois ou mais booleans descrevendo o mesmo ciclo de vida — pagamento, pedido, sessão, o que for — e existe pelo menos uma combinação que o negócio proíbe. A migração incremental (guardar PaymentState, derivar os booleans antigos) evita reescrever callers de uma vez.
Recua quando os flags são genuinamente independentes — todas as 2^n combinações fazem sentido — ou quando o conjunto de estados muda com frequência e cada novo permits obriga a tocar todo switch exaustivo do código-base; nesse caso a rigidez que ajuda hoje vira atrito amanhã, e um enum ou um record de flags é mais barato para manter.
Próximo passo: pegue o tipo do seu projeto com mais de dois booleans relacionados, escreva no papel os estados que o negócio realmente permite, e comece pelo sealed interface — o adaptador de leitura para o código legado pode esperar até o primeiro switch quebrar em build.