Sealed classes y pattern matching para switch en Java 21

1. Descripción general

Cuando un tipo de datos tiene solo unas pocas variantes fijas, por ejemplo, pago con tarjeta, transferencia bancaria o billetera digital, usualmente los procesamos con una cadena de if (x instanceof ...). Esta forma es verbosa. Peor aún, es fácil olvidar un caso sin que nadie lo detecte.

Java 21 resuelve este problema con tres características que funcionan juntas: sealed classes (oficial desde Java 17), pattern matching para switch (JEP 441) y record patterns (JEP 440). Después de leer este artículo, sabrás cómo modelar un conjunto cerrado de tipos, escribir un switch que el compilador verifique automáticamente para cubrir todos los casos, y evitar errores comunes.

2. El problema con el enfoque antiguo

Supongamos que necesitas calcular una comisión para cada tipo de pago. Antes de Java 21, el código usualmente se veía así:

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("No soportado: " + payment);
}

Este código tiene tres problemas:

  • Se olvidó de EWallet, pero el código sigue compilando. El error solo aparece en tiempo de ejecución.
  • La rama throw al final es obligatoria, aunque desde el punto de vista del negocio nunca debería ocurrir.
  • Cuando añades un nuevo tipo de pago, tienes que encontrar manualmente todos los lugares donde existe esta cadena de if para actualizarlos.

3. Sealed classes

3.1. Declarar una sealed interface

Una sealed class o sealed interface lista exactamente qué clases pueden heredarla:

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 {}

La palabra clave sealed junto con la cláusula permits le dice al compilador que Payment tiene exactamente tres tipos hijos. Si otra clase, como class Crypto implements Payment, intenta heredarla, obtendrás un error de compilación.

Si las clases hijas están en el mismo archivo que la clase padre, puedes omitir permits. El compilador deducirá automáticamente la lista.

3.2. Reglas para clases hijas

Cada clase hija directa de un sealed type debe declarar uno de estos tres modificadores:

Modificador Significado
final Nadie más puede heredarla
sealed Continúa limitándola con su propio permits
non-sealed Abre a cualquier clase que quiera heredarla

Los records son final por defecto, así que el ejemplo anterior no necesita nada más. Con clases regulares, debes especificarlo:

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 es non-sealed
public class ZaloNotification extends SmsNotification {}

Aquí EmailNotification está completamente bloqueada. En cambio, SmsNotification está abierta a cualquiera que quiera heredarla. Como consecuencia, cuando haces switch sobre Notification, la rama SmsNotification también cubrirá ZaloNotification.

3.3. Restricciones sobre paquetes y módulos

Las clases en permits deben estar en el mismo módulo que la clase padre. Si el proyecto no usa módulos (unnamed module), deben estar en el mismo paquete. Esto es intencional en el diseño: el autor de la clase padre controla toda la jerarquía.

4. Pattern matching para switch

4.1. Type pattern

A partir de Java 21, las etiquetas case pueden ser un pattern de tipo seguido de una variable:

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 el tipo como asigna la variable ya convertida (c, b, w). La variable solo está disponible dentro de su rama. El código no tiene default ni throw.

4.2. Exhaustiveness: el compilador verifica todos los casos

Porque Payment es sealed, el compilador sabe exactamente cuáles son sus tres tipos hijos. Si eliminas la línea case EWallet w -> 0;, el código no compilará:

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

Este es el beneficio más grande. Cuando añades QrPayment a permits, todos los switch sobre Payment en tu código mostrarán errores hasta que los actualices. El compilador actúa como tu lista de tareas.

Nota: Si las clases se compilan por separado y en tiempo de ejecución aparece un tipo hijo que el switch no conoce, la JVM lanzará una MatchException. El compilador inserta automáticamente esta rama para ti.

4.3. Guard con when

Puedes añadir una condición a un pattern 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;
    };
}

Las transacciones de tarjeta desde 10 millones en adelante pagan comisión de 1%, el resto paga 2%. Una rama con guard no se considera como cubriendo completamente a CardPayment. Por lo tanto, la segunda rama case CardPayment c sigue siendo obligatoria.

Probémoslo con algunas transacciones:

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 + " -> comisión " + calculator.fee(p)));
    }
}

Resultado:

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

4.4. Orden de case y dominancia

Un switch intenta los patterns de arriba hacia abajo. Si una rama más general está antes de una más específica, la rama posterior nunca se ejecutará. El compilador lo detecta:

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

Regla fácil de recordar: coloca las ramas específicas (con guard o tipos más concretos) arriba, las ramas generales abajo.

4.5. Manejo de null

Por defecto, un switch que encuentra null lanzará una NullPointerException, como el comportamiento antiguo. Si quieres manejar null explícitamente, añades case null:

public String label(Payment payment) {
    return switch (payment) {
        case null -> "Método de pago no seleccionado";
        case CardPayment c -> "Tarjeta";
        case BankTransfer b -> "Transferencia";
        case EWallet w -> "Billetera " + w.provider();
    };
}

También puedes combinar case null, default -> si tu switch tiene una rama default.

5. Record patterns

Record patterns permiten destructurar los componentes de un record directamente en el case. Así no necesitas llamar a los accessors:

public String describe(Payment payment) {
    return switch (payment) {
        case CardPayment(String number, long amount) ->
                "Tarjeta ****" + number.substring(number.length() - 4) + ": " + amount;
        case BankTransfer(var bank, var amount) -> "Transferencia " + bank + ": " + amount;
        case EWallet(var provider, var amount) -> "Billetera " + provider + ": " + amount;
    };
}

CardPayment(String number, long amount) coincide cuando payment es un CardPayment, y asigna number y amount. Puedes usar var para que el compilador deduzca el tipo. Con new CardPayment("4111-1111", 500_000), el resultado es Tarjeta ****1111: 500000.

El verdadero poder de record patterns radica en su capacidad para anidarse:

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 ->
                "Requiere aprobación: tarjeta " + number;
        case Charge(Payment p) -> "Cobrar " + p.amount();
        case Refund(var original, var reason) -> "Reembolsar " + original.amount() + " (" + reason + ")";
    };
}

La primera rama coincide con un Charge que contiene un CardPayment con valor grande, todo en una línea. El compilador sigue verificando exhaustiveness para el pattern anidado: Charge(Payment p) cubre todos los Charge, y Refund(...) cubre todos los Refund.

Nota: El pattern sin nombre _, como en case Refund(_, var reason), es solo preview en Java 21 y requiere --enable-preview. Esta característica es oficial a partir de Java 22. Si tu proyecto está fijo en Java 21, usa var con un nombre de variable normal.

6. Pruebas

Necesitas Java 21 junto con JUnit 5 y AssertJ. Si usas Spring Boot 3.x, spring-boot-starter-test ya incluye ambos. Con un proyecto 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>

Pruebas para FeeCalculator de la sección 4.3 y describe de la sección 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("Tarjeta ****1111: 500000")
                .doesNotContain("4111-");
    }
}

Las pruebas deben enfocarse en los límites del guard (justo debajo y arriba de 10 millones), porque ese es el lugar donde el orden de case es más propenso a errores. La exhaustiveness no requiere pruebas: el compilador ya se encarga de eso.

7. Errores comunes y cuándo no usar esto

Añadir default a un switch sobre un sealed type. El código sigue compilando, pero pierdes el beneficio principal. Cuando añades un nuevo tipo hijo, silenciosamente caerá en default en lugar de causar un error de compilación. Con sealed types, lista todos los casos y olvida el default.

Olvidar que una rama con when no cubre todo el tipo. Si cada case CardPayment tiene un guard, el compilador dirá que faltan casos. Siempre necesitas una rama sin guard, o una rama más general, al final.

Usar switch en lugar de polimorfismo en todas partes. Si el comportamiento realmente pertenece al objeto mismo, un método abstracto en la clase padre sigue siendo mejor. Pattern matching es apropiado cuando la lógica está fuera de la jerarquía, como calcular comisiones, renderizar, mapear a DTO, y no quieres meter todo en el record.

Nota: No uses sealed classes cuando:

  • Escribes una biblioteca o punto de extensión (plugin) donde los usuarios necesitan añadir sus propios tipos.
  • La clase es una entidad JPA que necesita lazy loading. Hibernate crea proxies heredando de la entidad, así que sealed o final lo impedirá.
  • Los tipos hijos están dispersos en varios módulos. Sealed requiere que estén en el mismo módulo, o en el mismo paquete si no usas módulos.

Serializar JSON. Jackson no sabe automáticamente cómo deserializar un Payment al tipo correcto solo porque la interfaz es sealed. Aún necesitas configurar la información de tipo, por ejemplo @JsonTypeInfo junto con @JsonSubTypes.

8. Conclusión

Las sealed classes te permiten declarar explícitamente que un tipo tiene solo un conjunto fijo de variantes. Pattern matching para switch y record patterns convierten ese conjunto en código conciso, verificado por el compilador para cubrir todos los casos. Puedes usar guard when para añadir condiciones, case null para manejar null explícitamente, y patterns anidados para profundizar en los datos.

Tres principios a recordar: omite default cuando haces switch sobre un sealed type, coloca ramas específicas antes que generales, y usa sealed solo para jerarquías de tipos que realmente quieras cerrar.

javajava-21sealed-classespattern-matchingswitch