Blog

Sealed types e pattern matching em Java

Sealed types dão ao Java uma lista fechada de subtipos, e o pattern matching deixa um switch testar e desmontar cada um. Juntos, eles transformam um caso esquecido num erro de compilação em vez de um bug silencioso.

Um sealed type lista toda classe que tem permissão para estendê-lo ou implementá-lo. O pattern matching deixa um switch conferir o tipo de um valor e tirar os campos dele no mesmo passo. Junte os dois e o compilador conhece todo caso que o seu código precisa tratar. Assim, quando alguém adiciona um caso novo, o build aponta cada lugar que esqueceu dele.

Este post começa pelo bug que os sealed types evitam. Depois cobre sealed e permits, type patterns, record patterns, guards com when, exaustividade, dominância, null e o unnamed pattern _. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e rode java Main.java.

O problema: uma verificação de tipo que esquece um caso

Uma cadeia de verificações instanceof com um else no fim compila sem reclamar quando aparece um subtipo novo, e o subtipo novo cai no else em silêncio. Aqui está um pequeno sistema de pagamentos escrito do jeito que o código Java era por anos:

interface Payment {}

class Card implements Payment {
    final int amount;

    Card(int amount) {
        this.amount = amount;
    }
}

class BankTransfer implements Payment {
    final int amount;

    BankTransfer(int amount) {
        this.amount = amount;
    }
}

class Crypto implements Payment {
    final int amount;

    Crypto(int amount) {
        this.amount = amount;
    }
}

int fee(Payment p) {
    if (p instanceof Card) {
        Card c = (Card) p;
        return c.amount * 2 / 100;
    } else if (p instanceof BankTransfer) {
        return 1;
    } else {
        return 0;
    }
}

void main() {
    IO.println("card fee:   " + fee(new Card(250)));
    IO.println("bank fee:   " + fee(new BankTransfer(250)));
    IO.println("crypto fee: " + fee(new Crypto(250)));
}

Ele imprime:

card fee:   5
bank fee:   1
crypto fee: 0

Crypto foi adicionado depois que fee foi escrito. Ninguém atualizou fee, então todo pagamento em cripto agora sai de graça. Nada quebrou, e nada avisou você.

O compilador não tem como ajudar aqui, porque Payment é aberto. Qualquer classe, em qualquer lugar, pode implementá-lo, então o compilador não tem uma lista para comparar com a sua cadeia de if. O else é um palpite sobre tipos que ainda não existem, e o palpite estava errado.

Repare também no cast. p instanceof Card confere o tipo, e depois (Card) p diz a mesma coisa de novo. Os dois problemas têm solução no Java moderno.

Interfaces sealed: uma lista fechada de subtipos

Uma interface sealed nomeia os únicos tipos que podem implementá-la, numa cláusula permits. Classes e interfaces sealed viraram um recurso final no Java 17.

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

void main() {
    Payment p = new Card(250, "EUR");
    IO.println(p);
    IO.println(Payment.class.isSealed());
}

Ele imprime:

Card[amount=250, currency=EUR]
true

Card e BankTransfer são records, que são os tipos-folha naturais de uma hierarquia sealed: cada um é um pacotinho imutável de valores com nome. A parte sobre records trata deles com calma.

A lista é obrigatória. Tente adicionar um terceiro tipo de pagamento sem colocá-lo em permits:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Crypto(int amount, String coin) implements Payment {}

void main() {
    IO.println(new Crypto(250, "BTC"));
}

O build falha com:

Main.java:7: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)

A mensagem diz “sealed class” mesmo que Payment seja uma interface, e “extend” mesmo que Crypto a implemente. O javac usa as mesmas palavras para os dois casos. O que importa é a segunda metade: Crypto não está na lista.

Todo subtipo permitido precisa dizer o que vem depois

Cada tipo em permits precisa ser marcado como final, sealed ou non-sealed, para que a hierarquia fique fechada até o fim. Uma classe comum não é aceita:

sealed interface Payment permits Card, BankTransfer, GiftCard {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

class GiftCard implements Payment {
    int balance = 50;
}

void main() {
    IO.println(new GiftCard().balance);
}

O build falha com:

Main.java:7: error: sealed, non-sealed or final modifiers expected

As três opções significam:

  • final: nada pode estendê-lo. Records são final implicitamente, e é por isso que Card e BankTransfer não precisaram de modificador.
  • sealed: ele tem a sua própria lista permits, um nível abaixo.
  • non-sealed: qualquer um pode estendê-lo. Você abre um galho de propósito e abre mão da lista fechada nesse galho.

Aqui o non-sealed deixa uma classe que não está em nenhuma lista entrar por meio de GiftCard:

sealed interface Payment permits Card, BankTransfer, GiftCard {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

non-sealed class GiftCard implements Payment {
    int balance() {
        return 50;
    }
}

class StoreCredit extends GiftCard {
    @Override
    int balance() {
        return 20;
    }
}

void main() {
    Payment p = new StoreCredit();
    IO.println(p instanceof GiftCard);
    IO.println(((GiftCard) p).balance());
}

Ele imprime:

true
20

StoreCredit não aparece em lugar nenhum de Payment, mas mesmo assim é um Payment, porque é um GiftCard. O compilador ainda sabe que o nível de cima tem exatamente três galhos. Ele só não consegue listar o que fica abaixo de GiftCard.

Quando você pode deixar permits de fora

Se os subtipos permitidos estão declarados no mesmo arquivo que o sealed type, você pode tirar o permits, e o compilador junta a lista para você:

sealed interface Payment {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

void main() {
    for (var type : Payment.class.getPermittedSubclasses()) {
        IO.println(type.getSimpleName());
    }
}

Ele imprime:

Card
BankTransfer

Todo programa deste post é um arquivo só, então permits é opcional em todos eles. O resto do post mantém a cláusula mesmo assim, para você ver a lista. Num projeto de verdade, os subtipos normalmente ficam em arquivos próprios, e aí permits é obrigatório.

Type patterns: conferir o tipo e dar um nome num passo só

Um type pattern, como p instanceof Card c, confere o tipo e, quando casa, entrega uma variável daquele tipo. Não há cast para escrever. O pattern matching para instanceof virou final no Java 16.

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String describe(Payment p) {
    if (p instanceof Card c && c.amount() >= 100) {
        return "large card payment in " + c.currency();
    }
    if (!(p instanceof Card c)) {
        return "not a card";
    }
    return "small card payment of " + c.amount();
}

void main() {
    IO.println(describe(new Card(250, "EUR")));
    IO.println(describe(new Card(40, "EUR")));
    IO.println(describe(new BankTransfer(900, "DE89 3704")));
}

Ele imprime:

large card payment in EUR
small card payment of 40
not a card

c se chama binding variable (variável de ligação). Ela só existe onde o match é certo:

  • Depois de &&: o lado direito só roda quando o lado esquerdo casou, então c.amount() é seguro ali.
  • Depois de uma verificação negada que retorna: if (!(p instanceof Card c)) return ... significa que toda linha abaixo dela só roda para um cartão, então c continua no escopo até o fim do método.

Troque && por || e o build falha com cannot find symbol, porque o lado direito do || roda justamente quando o match falhou.

switch sobre um sealed type não precisa de default

Um switch pode usar type patterns como cases e, sobre um sealed type, pode listar todo subtipo permitido sem nenhum default. Patterns no switch viraram final no Java 21.

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(new BankTransfer(250, "DE89 3704")));
}

Ele imprime:

5
1

Compare com a cadeia de if do começo. Não há cast nem else. O compilador aceitou o switch sem default porque leu o permits, viu dois tipos e achou um case para cada um. Um switch que cobre todo valor possível se chama exaustivo.

Adicionar um subtipo quebra o build, de propósito

A exaustividade compensa no dia em que alguém adiciona um tipo permitido novo. Aqui está Crypto adicionado a Payment, com fee sem nenhuma mudança:

sealed interface Payment permits Card, BankTransfer, Crypto {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Crypto(int amount, String coin) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
    };
}

void main() {
    IO.println(fee(new Crypto(250, "BTC")));
}

O build falha com:

Main.java:10: error: the switch expression does not cover all possible input values
    return switch (p) {
           ^

É o mesmo erro da cadeia de if do começo. Aquela colocou em produção um pagamento em cripto de graça. Esta não compila. Numa base de código grande, todo switch sobre Payment falha de uma vez, e a lista de erros é a sua lista de tarefas.

Uma instrução switch passa pela mesma verificação quando usa patterns. O javac reporta the switch statement does not cover all possible input values. Uma instrução switch no estilo antigo sobre um enum, sem patterns, ainda pode pular valores.

Um default joga a segurança fora

Adicione default ao mesmo switch e ele volta a compilar, com o bug antigo de volta:

sealed interface Payment permits Card, BankTransfer, Crypto {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Crypto(int amount, String coin) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
        default -> 0;
    };
}

void main() {
    IO.println("crypto fee: " + fee(new Crypto(250, "BTC")));
}

Ele imprime:

crypto fee: 0

O default casa com tudo que nenhum outro case pegou, e isso inclui tipos que nem existiam quando você o escreveu. O compilador não tem do que reclamar, então não reclama. Sobre um sealed type, deixe o default de fora e liste os cases. Aí o compilador lembra por você.

Explicado como se você tivesse dez anos

Imagine um brinquedo de encaixar formas. A caixa diz que ele vem com exatamente três formas: uma estrela, um quadrado e um círculo. Essa lista impressa na caixa é o sealed type.

A sua tampa é o switch. Como você sabe que só existem três formas, pode conferir a tampa antes de brincar: um buraco de estrela, um de quadrado, um de círculo. Toda forma tem um buraco, então nada fica entalado.

No ano seguinte, a empresa adiciona um triângulo e imprime uma lista nova na caixa. Assim que você compara a sua tampa velha com a lista nova, vê que não há buraco de triângulo. Você descobre antes de qualquer triângulo aparecer.

Um default é um buracão cortado no meio da tampa. O triângulo cai direto por ele, e você nunca percebe que esqueceu dele.

A versão precisa

A lista permits de um sealed type faz parte do arquivo de classe compilado. Quando o javac compila um switch sobre um sealed type, ele confere se os cases cobrem todo subtipo permitido, descendo pelos subtipos sealed até as listas deles. Se não cobrem, e não há default, o switch não compila.

Dois detalhes deixam a verificação mais rígida do que parece. Um case com guard (when, visto mais abaixo) não conta para a cobertura, porque o compilador não tem como saber se o guard vai ser verdadeiro. E a verificação precisa de uma lista fechada: o mesmo switch sobre uma interface comum, não sealed, falha com o mesmo erro até você adicionar default.

Onde a analogia falha: a verificação acontece quando você compila, não quando uma forma chega. Se Payment ganha Crypto e é recompilado, mas a classe que contém o seu switch não é, nada confere a sua tampa de novo. Mesmo assim, o Java não deixa o tipo novo passar. O compilador adiciona um ramo escondido a todo switch exaustivo, e um Crypto que chega ali lança java.lang.MatchException. Foi isso que vimos quando testamos com dois arquivos compilados separadamente. Recompile tudo e você recebe o erro de compilação no lugar.

Record patterns: desmontar o record no case

Um record pattern, como Card(int amount, String currency), casa com o tipo e tira os componentes do record para variáveis, num case só. Record patterns viraram final no Java 21.

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String describe(Payment p) {
    return switch (p) {
        case Card(int amount, String currency) -> amount + " " + currency + " by card";
        case BankTransfer(var amount, var iban) -> amount + " from account " + iban;
    };
}

void main() {
    IO.println(describe(new Card(250, "EUR")));
    IO.println(describe(new BankTransfer(900, "DE89 3704")));

    Object thing = new Card(40, "USD");
    if (thing instanceof Card(int amount, String currency)) {
        IO.println("unpacked " + amount + " and " + currency);
    }
}

Ele imprime:

250 EUR by card
900 from account DE89 3704
unpacked 40 and USD

Os componentes saem na ordem em que o record os declara. Os nomes no pattern ficam à sua escolha. Eles não precisam ser iguais aos nomes do record, mas usar os mesmos deixa o código legível. O var deixa o compilador preencher cada tipo, como no case de BankTransfer.

Record patterns também funcionam com instanceof, como mostram as últimas linhas.

Guards com when

Um guard adiciona uma condição a um case: case Card c when c.amount() < 100 só casa com cartões abaixo de 100. Se o pattern casa mas o guard é falso, o switch segue para o próximo case.

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String fee(Payment p) {
    return switch (p) {
        case BankTransfer(int amount, String iban) -> "flat fee 1 from " + iban;
        case Card(int amount, String currency) when amount < 100 -> "no fee";
        case Card(int amount, String currency) -> "fee " + amount * 2 / 100 + " " + currency;
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(new Card(40, "EUR")));
    IO.println(fee(new BankTransfer(900, "DE89 3704")));
}

Ele imprime:

fee 5 EUR
no fee
flat fee 1 from DE89 3704

Pagamentos pequenos no cartão são grátis, os maiores pagam 2%, e uma transferência paga 1 fixo. O guard pode usar as variáveis que o próprio pattern acabou de ligar, como amount < 100 faz.

O terceiro case não tem guard, e é isso que mantém o switch exaustivo. Apague esse case e o build falha com the switch expression does not cover all possible input values, mesmo com um case de cartão ainda ali. Um case com guard nunca conta para a cobertura.

Vendo um switch escolher um case

Os cases de um switch com patterns são testados de cima para baixo, e a animação acompanha new Card(250, "EUR") pelo switch acima:

p new Card(250, "EUR") switch (p) testa os cases de cima case BankTransfer(int amount, String iban) -> case Card(int amount, String currency) when amount < 100 -> case Card(int amount, String currency) -> não é BankTransfer: pula guard falso: pula casa: este case roda amount = 250, currency = "EUR" valor: "fee 5 EUR" o switch recebe p = new Card(250, "EUR") e testa os cases de cima case 1: p não é um BankTransfer, então o record pattern não casa case 2: o pattern Card casa e liga amount, mas o guard é falso case 3: o pattern Card casa e não tem guard, então este case vence o pattern tira amount e currency do record a expressão após -> roda, e o valor dela é o resultado do switch

new Card(250, “EUR”) testado contra três cases, em ordem. O pattern de BankTransfer falha no tipo. O primeiro pattern de Card casa, mas o guard dele, amount < 100, é falso, então o case é pulado. O pattern de Card sem guard casa, liga amount e currency, e a expressão dele vira o resultado.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. O switch recebe new Card(250, "EUR") e começa pelo primeiro case.
  2. case BankTransfer(int amount, String iban) confere o tipo primeiro. O valor é um Card, então o pattern não casa e nada é ligado.
  3. case Card(int amount, String currency) when amount < 100 casa com o tipo e liga amount a 250. Aí o guard roda. 250 < 100 é falso, então esse case é pulado.
  4. case Card(int amount, String currency) casa e não tem guard, então esse case é o escolhido.
  5. O pattern liga amount a 250 e currency a "EUR".
  6. A expressão depois de -> roda e produz "fee 5 EUR", que vira o valor do switch inteiro. Nenhum case seguinte é testado.

Patterns aninhados, e _ para as partes que você não usa

Record patterns se aninham, então um case pode entrar num record que guarda outros records. O unnamed pattern _ ocupa o lugar de qualquer componente de que você não precisa. Ele virou final no Java 22.

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

record Customer(String name, boolean vip) {}

record Order(Customer customer, Payment payment) {}

int fee(Order order) {
    return switch (order) {
        case Order(Customer(_, boolean vip), _) when vip -> 0;
        case Order(_, Card(int amount, String currency)) when currency.equals("EUR") ->
            amount * 2 / 100;
        case Order(_, Card(int amount, _)) -> amount * 3 / 100;
        case Order(_, BankTransfer _) -> 1;
    };
}

void main() {
    var ana = new Customer("Ana", true);
    var ben = new Customer("Ben", false);
    IO.println(fee(new Order(ana, new Card(250, "EUR"))));
    IO.println(fee(new Order(ben, new Card(250, "EUR"))));
    IO.println(fee(new Order(ben, new Card(250, "USD"))));
    IO.println(fee(new Order(ben, new BankTransfer(250, "DE89 3704"))));
}

Ele imprime:

0
5
7
1

Leia os cases de cima para baixo:

  • Clientes VIP não pagam nada. O pattern entra no cliente, liga vip e ignora o nome e o pagamento inteiro com _.
  • Cartões em euro pagam 2%. O pattern pula o cliente e entra no pagamento.
  • Os outros cartões pagam 3%. Card(int amount, _) precisa do valor, mas não da moeda. 3% de 250 é 7,5, e a divisão inteira dá 7.
  • Transferências pagam 1. BankTransfer _ casa com o tipo e não liga nada.

_ diz “tem algo aqui, e eu não vou usar”, o que informa tanto quem lê quanto o compilador. Você não pode ler _ depois, e pode usá-lo mais de uma vez no mesmo pattern.

Um recurso parecido ainda está em preview. Tipos primitivos em patterns, como case int i when i < 10, são um recurso em preview no Java 25, e o javac os rejeita a menos que você passe --enable-preview. Esta série não usa esse recurso.

A ordem importa: um case amplo pode esconder um estreito

Um case que nunca pode ser alcançado é um erro de compilação, e o jeito mais comum de escrever um é colocar um case amplo acima de um mais estreito. Aqui o case de cartão com guard vem depois do case simples:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String fee(Payment p) {
    return switch (p) {
        case Card c -> "fee " + c.amount() * 2 / 100;
        case Card c when c.amount() < 100 -> "no fee";
        case BankTransfer t -> "flat fee 1";
    };
}

void main() {
    IO.println(fee(new Card(40, "EUR")));
}

O build falha com:

Main.java:10: error: this case label is dominated by a preceding case label
        case Card c when c.amount() < 100 -> "no fee";
             ^

case Card c casa com todo cartão, então um cartão abaixo de 100 nunca chegaria à linha de baixo. O compilador chama isso de dominância: o primeiro case domina o segundo. Se isso compilasse, pagamentos pequenos seriam cobrados em silêncio.

A correção é ordenar os cases do mais estreito para o mais amplo, como fez o exemplo dos guards. O mesmo erro aparece se você colocar case Payment q acima de case BankTransfer t, porque toda transferência é um pagamento.

null e switch

Um switch com patterns lança NullPointerException quando o valor é null, a menos que um dos cases seja case null. A exaustividade não cobre null, então um switch que cobre todo tipo ainda falha em tempo de execução:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

int fee(Payment p) {
    return switch (p) {
        case Card c -> c.amount() * 2 / 100;
        case BankTransfer t -> 1;
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(null));
}

Ele imprime e para:

5
Exception in thread "main" java.lang.NullPointerException

A exceção não tem mensagem. As mensagens detalhadas de NullPointerException do Java normalmente dizem qual variável era null, mas esta não diz. O primeiro frame da stack trace é java.util.Objects.requireNonNull, que o compilador inseriu antes do switch. Se você vir uma NPE sem mensagem numa linha com switch, confira o valor usado no switch.

Quando null é um valor esperado, diga isso com case null:

sealed interface Payment permits Card, BankTransfer {}

record Card(int amount, String currency) implements Payment {}

record BankTransfer(int amount, String iban) implements Payment {}

String fee(Payment p) {
    return switch (p) {
        case null -> "no payment yet";
        case Card c -> "fee " + c.amount() * 2 / 100;
        case BankTransfer t -> "flat fee 1";
    };
}

void main() {
    IO.println(fee(new Card(250, "EUR")));
    IO.println(fee(null));

    Payment missing = null;
    IO.println(missing instanceof Card c ? "card " + c.amount() : "not a card");
}

Ele imprime:

fee 5
no payment yet
not a card

case null é só mais um case, e adicioná-lo não atrapalha a exaustividade. O instanceof nunca precisou de um: null instanceof Card é simplesmente falso, então a última linha foi para o ramo “not a card”. Num switch sobre um tipo que não é sealed, case null, default -> trata as duas sobras numa linha só.

O que lembrar

  • Uma interface ou classe sealed lista os seus subtipos permitidos. Cada um precisa ser final, sealed ou non-sealed, e records já são final.
  • x instanceof Card c confere o tipo e liga c sem cast. Record patterns como Card(int amount, String currency) também tiram os componentes.
  • Um switch sobre um sealed type que cobre todo subtipo não precisa de default. Adicione um subtipo novo e cada switch que não o trata para de compilar.
  • Um default esconde os subtipos novos, o que traz de volta o bug silencioso. Um case com guard não conta para cobrir um tipo.
  • Os cases são testados de cima para baixo. Coloque os cases estreitos acima dos amplos, ou o javac reporta que um case é dominado.
  • Um switch com patterns lança NullPointerException com null, a menos que tenha case null.
  • Use _ para os componentes de que você não precisa.

Um sealed type dá ao compilador a lista completa, então ele pode dizer qual case você esqueceu.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.