Blog

Sealed types y pattern matching en Java

Los sealed types le dan a Java una lista cerrada de subtipos, y el pattern matching permite que un switch pruebe y desarme cada uno. Juntos convierten un caso olvidado en un error de compilación en lugar de un bug silencioso.

Un sealed type lista cada clase que tiene permitido extenderlo o implementarlo. El pattern matching permite que un switch compruebe el tipo de un valor y saque sus campos en el mismo paso. Si juntas los dos, el compilador conoce cada caso que tu código tiene que manejar, así que cuando alguien agrega uno nuevo, la compilación señala cada lugar que lo olvidó.

Este post empieza con el bug que los sealed types evitan. Después cubre sealed y permits, los patrones de tipo, los record patterns, las guardas con when, la exhaustividad, la dominancia, null y el patrón sin nombre _. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.

El problema: una comprobación de tipos que olvida un caso

Una cadena de comprobaciones instanceof con un else al final compila sin quejarse cuando aparece un subtipo nuevo, y el subtipo nuevo cae en el else sin avisar. Aquí hay un pequeño sistema de pagos escrito como se escribía el código Java durante años:

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)));
}

Imprime:

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

Crypto se agregó después de escribir fee. Nadie actualizó fee, así que ahora cada pago con cripto es gratis. Nada falló y nada te avisó.

El compilador no puede ayudar aquí, porque Payment está abierta. Cualquier clase, en cualquier lugar, puede implementarla, así que el compilador no tiene una lista contra la cual revisar tu cadena de if. El else es una suposición sobre tipos que todavía no existen, y la suposición salió mal.

Fíjate también en el cast. p instanceof Card comprueba el tipo, y después (Card) p lo repite. Los dos problemas tienen solución en el Java moderno.

Interfaces sealed: una lista cerrada de subtipos

Una interfaz sealed nombra los únicos tipos que pueden implementarla, en una cláusula permits. Las clases e interfaces sealed pasaron a ser una característica final en 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());
}

Imprime:

Card[amount=250, currency=EUR]
true

Card y BankTransfer son records, que son los tipos hoja naturales de una jerarquía sealed: cada uno es una bolsa pequeña e inmutable de valores con nombre. La parte sobre records los cubre a fondo.

La lista se hace cumplir. Intenta agregar un tercer tipo de pago sin agregarlo a 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"));
}

La compilación falla con:

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

El mensaje dice “sealed class” aunque Payment es una interfaz, y “extend” aunque Crypto la implementa. javac usa las mismas palabras para los dos casos. Lo que importa es la segunda mitad: Crypto no está en la lista.

Cada subtipo permitido debe decir qué viene después

Cada tipo en permits tiene que estar marcado como final, sealed o non-sealed, para que la jerarquía siga cerrada hasta abajo. Una clase común no está permitida:

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);
}

La compilación falla con:

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

Las tres opciones significan:

  • final: nada puede extenderlo. Los records son final de forma implícita, por eso Card y BankTransfer no necesitaron ningún modificador.
  • sealed: tiene su propia lista permits, un nivel más abajo.
  • non-sealed: cualquiera puede extenderlo. Abres una rama a propósito, y renuncias a la lista cerrada para esa rama.

Aquí non-sealed deja que una clase que no está en ninguna lista entre a través 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());
}

Imprime:

true
20

StoreCredit no aparece en ningún lugar de Payment, y aun así es un Payment, porque es un GiftCard. El compilador sigue sabiendo que el nivel superior tiene exactamente tres ramas. Solo que no puede listar lo que hay debajo de GiftCard.

Cuándo puedes omitir permits

Si los subtipos permitidos se declaran en el mismo archivo que el sealed type, puedes quitar permits, y el compilador los reúne por ti:

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());
    }
}

Imprime:

Card
BankTransfer

Cada programa de este post es un solo archivo, así que permits es opcional en todos. El resto del post lo mantiene de todos modos, para que veas la lista. En un proyecto real, los subtipos suelen vivir en sus propios archivos, y entonces permits es obligatorio.

Patrones de tipo: comprobar el tipo y nombrarlo en un paso

Un patrón de tipo (type pattern), como p instanceof Card c, comprueba el tipo y, cuando coincide, te da una variable de ese tipo. No hay cast que escribir. El pattern matching para instanceof pasó a ser final en 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")));
}

Imprime:

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

c se llama variable de enlace (binding variable). Solo existe donde la coincidencia es segura:

  • Después de &&: el lado derecho solo se ejecuta cuando el izquierdo coincidió, así que c.amount() es seguro ahí.
  • Después de una comprobación negada que retorna: if (!(p instanceof Card c)) return ... significa que cada línea de abajo solo se ejecuta para una tarjeta, así que c sigue en alcance durante el resto del método.

Cambia && por || y la compilación falla con cannot find symbol, porque el lado derecho de || se ejecuta justo cuando la coincidencia falló.

Un switch sobre un sealed type no necesita default

Un switch puede usar patrones de tipo como casos, y sobre un sealed type puede listar cada subtipo permitido sin ningún default. Los patrones en switch pasaron a ser finales en 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")));
}

Imprime:

5
1

Compara esto con la cadena de if del principio. No hay cast ni else. El compilador aceptó el switch sin default porque leyó permits, vio dos tipos y encontró un caso para cada uno. Un switch que cubre todos los valores posibles se llama exhaustivo.

Agregar un subtipo rompe la compilación, a propósito

La exhaustividad rinde frutos el día que alguien agrega un tipo permitido nuevo. Aquí Crypto se agregó a Payment, y fee quedó sin tocar:

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")));
}

La compilación falla con:

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

Es el mismo error que la cadena de if del principio. Aquella llegó a producción con un pago con cripto gratis. Esta no compila. En un código grande, cada switch sobre Payment falla a la vez, y la lista de errores es tu lista de pendientes.

Una sentencia switch recibe la misma comprobación en cuanto usa patrones. javac reporta the switch statement does not cover all possible input values. Una sentencia switch al estilo antiguo sobre un enum, sin patrones, todavía puede saltarse valores.

Una rama default tira la seguridad a la basura

Agrega default al mismo switch y vuelve a compilar, con el bug de antes de regreso:

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")));
}

Imprime:

crypto fee: 0

default coincide con todo lo que ningún otro caso tomó, y eso incluye tipos que no existían cuando lo escribiste. El compilador no tiene de qué quejarse, así que no se queja. Sobre un sealed type, deja default fuera y lista los casos. Así el compilador se encarga de recordar por ti.

Explicado como si tuvieras diez años

Imagina un juguete para encajar figuras. La caja dice que trae exactamente tres figuras: una estrella, un cuadrado y un círculo. Esa lista impresa en la caja es el sealed type.

Tu tapa es el switch. Como sabes que solo hay tres figuras, puedes revisar la tapa antes de jugar: un hueco de estrella, un hueco de cuadrado, un hueco de círculo. Cada figura tiene su hueco, así que nada se puede atorar.

Al año siguiente, la empresa agrega un triángulo e imprime una lista nueva en la caja. En cuanto revisas tu tapa vieja contra la lista nueva, ves que no hay hueco de triángulo. Te enteras antes de que aparezca un solo triángulo.

Una rama default es un hueco grande cortado en medio de la tapa. El triángulo cae directo por ahí, y nunca notas que lo olvidaste.

La versión precisa

La lista permits de un sealed type forma parte de su archivo de clase compilado. Cuando javac compila un switch sobre un sealed type, comprueba que los casos cubran cada subtipo permitido, y baja por los subtipos sealed hasta sus propias listas. Si no los cubren y no hay default, el switch no compila.

Dos detalles hacen la comprobación más estricta de lo que parece. Un caso con guarda (when, que se cubre más abajo) no cuenta para la cobertura, porque el compilador no puede saber si la guarda será verdadera. Y la comprobación necesita una lista cerrada: el mismo switch sobre una interfaz común, no sealed, falla con el mismo error hasta que agregas default.

Dónde falla la analogía: la comprobación ocurre cuando compilas, no cuando llega una figura. Si Payment gana Crypto y se recompila, pero la clase que contiene tu switch no, nada vuelve a revisar tu tapa. Aun así, Java no deja pasar el tipo nuevo. El compilador agrega una rama oculta a cada switch exhaustivo, y un Crypto que llega ahí lanza java.lang.MatchException. Eso fue lo que obtuvimos al probarlo con dos archivos compilados por separado. Si recompilas todo, obtienes el error de compilación en su lugar.

Record patterns: desarmar el record en el caso

Un record pattern, como Card(int amount, String currency), coincide con el tipo y saca los componentes del record en variables, en un solo caso. Los record patterns pasaron a ser finales en 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);
    }
}

Imprime:

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

Los componentes salen en el orden en que el record los declara. Los nombres en el patrón los eliges tú. No tienen que coincidir con los nombres del record, aunque usar los mismos mantiene el código legible. var deja que el compilador complete cada tipo, como en el caso de BankTransfer.

Los record patterns también funcionan en instanceof, como muestran las últimas líneas.

Guardas con when

Una guarda agrega una condición a un caso: case Card c when c.amount() < 100 solo coincide con tarjetas de menos de 100. Si el patrón coincide pero la guarda es falsa, el switch pasa al siguiente caso.

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")));
}

Imprime:

fee 5 EUR
no fee
flat fee 1 from DE89 3704

Los pagos pequeños con tarjeta son gratis, los más grandes pagan 2%, y una transferencia paga una tarifa fija de 1. La guarda puede usar las variables que su propio patrón acaba de enlazar, como hace amount < 100.

El tercer caso no tiene guarda, y eso es lo que mantiene exhaustivo el switch. Bórralo y la compilación falla con the switch expression does not cover all possible input values, aunque todavía queda un caso de tarjeta. Un caso con guarda nunca cuenta para la cobertura.

Cómo un switch elige un caso

Los casos de un switch con patrones se prueban de arriba hacia abajo, y la animación sigue a new Card(250, "EUR") por el switch de arriba:

p new Card(250, "EUR") switch (p) prueba casos desde arriba case BankTransfer(int amount, String iban) -> case Card(int amount, String currency) when amount < 100 -> case Card(int amount, String currency) -> otro tipo: se omite guarda falsa: sigue coincide: se ejecuta amount = 250, currency = "EUR" valor: "fee 5 EUR" el switch recibe p = new Card(250, "EUR") y prueba desde arriba caso 1: p no es BankTransfer, así que el record pattern no coincide caso 2: el patrón Card coincide y enlaza amount, pero la guarda falla caso 3: el patrón Card coincide y no tiene guarda, así que gana el patrón saca amount y currency del record la expresión tras -> se ejecuta y da el resultado del switch

new Card(250, “EUR”) se prueba contra tres casos en orden. El patrón BankTransfer falla por el tipo. El primer patrón Card coincide, pero su guarda, amount < 100, es falsa, así que se omite. El patrón Card sin guarda coincide, enlaza amount y currency, y su expresión se vuelve el resultado.

Aquí están esos pasos en palabras, por si la animación no se reproduce:

  1. El switch recibe new Card(250, "EUR") y empieza por el primer caso.
  2. case BankTransfer(int amount, String iban) comprueba primero el tipo. El valor es un Card, así que el patrón no coincide y no se enlaza nada.
  3. case Card(int amount, String currency) when amount < 100 coincide con el tipo y enlaza amount a 250. Después se evalúa la guarda. 250 < 100 es falso, así que este caso se omite.
  4. case Card(int amount, String currency) coincide y no tiene guarda, así que se elige este caso.
  5. El patrón enlaza amount a 250 y currency a "EUR".
  6. La expresión después de -> se ejecuta y produce "fee 5 EUR", que se vuelve el valor de todo el switch. No se prueba ningún caso posterior.

Patrones anidados, y _ para las partes que no necesitas

Los record patterns se anidan, así que un solo caso puede meterse dentro de un record que contiene otros records. El patrón sin nombre _ ocupa el lugar de cualquier componente que no necesitas. Pasó a ser final en 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"))));
}

Imprime:

0
5
7
1

Lee los casos desde arriba:

  • Los clientes VIP no pagan nada. El patrón entra al cliente, enlaza vip e ignora el nombre y todo el pago con _.
  • Las tarjetas en euros pagan 2%. El patrón se salta el cliente y entra al pago.
  • Las demás tarjetas pagan 3%. Card(int amount, _) necesita el monto pero no la moneda. El 3% de 250 es 7.5, y la división entera da 7.
  • Las transferencias pagan 1. BankTransfer _ coincide con el tipo y no enlaza nada.

_ dice “aquí hay algo, y no lo estoy usando”, y eso le informa tanto a quien lee como al compilador. No puedes leer _ después, y puedes usarlo más de una vez en el mismo patrón.

Una característica relacionada sigue en preview. Los tipos primitivos en patrones, como case int i when i < 10, son una característica en preview en Java 25, y javac los rechaza a menos que pases --enable-preview. Esta serie no los usa.

El orden importa: un caso amplio puede ocultar uno más específico

Un caso al que nunca se puede llegar es un error de compilación, y la forma habitual de escribir uno es poner un caso amplio encima de uno más específico. Aquí el caso de tarjeta con guarda viene después del caso sin guarda:

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")));
}

La compilación falla con:

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 coincide con todas las tarjetas, así que una tarjeta de menos de 100 nunca llegaría a la línea de abajo. El compilador llama a eso dominancia (dominance): el primer caso domina al segundo. Si esto compilara, a los pagos pequeños se les cobraría una tarifa sin que nadie lo notara.

La solución es ordenar los casos del más específico al más amplio, como hizo el ejemplo de las guardas. El mismo error aparece si pones case Payment q encima de case BankTransfer t, porque toda transferencia es un pago.

null y switch

Un switch con patrones lanza NullPointerException cuando el valor es null, a menos que uno de sus casos sea case null. La exhaustividad no cubre null, así que un switch que cubre todos los tipos igual falla en tiempo de ejecución:

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));
}

Imprime y se detiene:

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

La excepción no tiene mensaje. Los mensajes útiles de NullPointerException de Java normalmente nombran la variable que era null, pero este no. El primer frame del stack trace es java.util.Objects.requireNonNull, que el compilador insertó antes del switch. Si ves una NPE sin mensaje en una línea con switch, revisa el valor sobre el que hiciste el switch.

Cuando null es un valor que esperas, dilo con 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");
}

Imprime:

fee 5
no payment yet
not a card

case null es solo otro caso, y agregarlo no altera la exhaustividad. instanceof nunca lo necesitó: null instanceof Card simplemente es falso, así que la última línea tomó la rama “not a card”. Para un switch sobre un tipo que no es sealed, case null, default -> maneja los dos sobrantes en una sola línea.

Qué recordar

  • Una interfaz o clase sealed lista sus subtipos permitidos. Cada uno debe ser final, sealed o non-sealed, y los records ya son final.
  • x instanceof Card c comprueba el tipo y enlaza c sin cast. Los record patterns como Card(int amount, String currency) además sacan los componentes.
  • Un switch sobre un sealed type que cubre cada subtipo no necesita default. Si agregas un subtipo nuevo, cada switch que no lo cubre deja de compilar.
  • Una rama default esconde los subtipos nuevos, y eso trae de vuelta el bug silencioso. Un caso con guarda no cuenta para cubrir un tipo.
  • Los casos se prueban de arriba hacia abajo. Pon los casos específicos encima de los amplios, o javac reporta que un caso está dominado.
  • Un switch con patrones lanza NullPointerException con null, a menos que tenga case null.
  • Usa _ para los componentes que no necesitas.

Un sealed type le da al compilador la lista completa, así que puede decirte qué caso olvidaste.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.