Sealed classes et pattern matching pour switch en Java 21

1. Vue d’ensemble

Quand un type de données n’a que quelques variantes fixes, par exemple paiement par carte, virement ou portefeuille électronique, nous les traitons habituellement avec une série de if (x instanceof ...). C’est verbeux. Pire encore, c’est facile d’oublier un cas sans que personne ne s’en aperçoive.

Java 21 résout ce problème avec trois fonctionnalités qui vont ensemble : sealed classes (officiel depuis Java 17), pattern matching pour switch (JEP 441) et record patterns (JEP 440). Après avoir lu cet article, vous saurez comment modéliser un ensemble fermé de types, écrire un switch dont le compilateur vérifie automatiquement la couverture, et éviter les erreurs courantes.

2. Le problème avec l’ancienne approche

Supposons que nous devions calculer les frais pour chaque type de paiement. Avant Java 21, le code ressemblait généralement à ceci :

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("Non supporté : " + payment);
}

Ce code a trois problèmes :

  • On a oublié EWallet, mais le code compile quand même. L’erreur n’apparaît qu’à l’exécution.
  • La branche throw finale est obligatoire, même si elle ne devrait jamais se produire en pratique.
  • Quand on ajoute un nouveau type de paiement, il faut trouver manuellement tous les endroits avec cette série de if pour les corriger.

3. Sealed classes

3.1. Déclarer une sealed interface

Une sealed class ou sealed interface liste exactement les classes autorisées à l’hériter :

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

Le mot-clé sealed avec la clause permits dit au compilateur que Payment n’a que trois types enfants exactement. Si une autre classe, comme class Crypto implements Payment, essaie d’en hériter, vous aurez une erreur de compilation.

Si les classes enfants sont dans le même fichier que la classe parent, vous pouvez omettre permits. Le compilateur en déduira la liste automatiquement.

3.2. Règles pour les classes enfants

Chaque classe enfant direct d’un sealed type doit déclarer l’un de ces trois modificateurs :

Modificateur Signification
final Personne d’autre ne peut en hériter
sealed Continuer à restreindre avec sa propre clause permits
non-sealed Ouvrir à toute classe qui veut hériter

Les records sont final par défaut, donc l’exemple ci-dessus n’a besoin de rien ajouter. Avec une classe classique, vous devez être explicite :

public sealed abstract class Notification permits EmailNotification, SmsNotification {}

public final class EmailNotification extends Notification {}

public non-sealed class SmsNotification extends Notification {}

// Valide car SmsNotification est non-sealed
public class ZaloNotification extends SmsNotification {}

Ici EmailNotification est complètement fermée. À l’inverse, SmsNotification est ouvert à quiconque veut en hériter. Par conséquent, quand on fait un switch sur Notification, la branche SmsNotification englobera aussi ZaloNotification.

3.3. Contraintes de package et de module

Les classes dans permits doivent se trouver dans le même module que la classe parent. Si le projet n’utilise pas de modules (unnamed module), elles doivent être dans le même package. C’est intentionnel : l’auteur de la classe parent contrôle toute la hiérarchie.

4. Pattern matching pour switch

4.1. Type pattern

À partir de Java 21, un label case peut être un pattern de type accompagné d’une 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;
        };
    }
}

Chaque case à la fois vérifie le type et assigne une variable déjà castée (c, b, w). La variable n’existe que dans la portée de sa branche. Le code n’a pas de default et pas de throw.

4.2. Exhaustiveness : le compilateur vérifie la couverture

Comme Payment est sealed, le compilateur connaît exactement ses trois types enfants. Si vous supprimez la ligne case EWallet w -> 0;, le code ne compilera pas :

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

C’est le plus grand avantage. Quand vous ajoutez QrPayment à permits, tous les switch sur Payment dans le codebase rapporteront une erreur jusqu’à être mis à jour. Le compilateur agit comme votre liste de tâches.

Note : Si les classes sont compilées séparément et qu’à l’exécution un type enfant apparaît que le switch ne connaît pas, la JVM lèvera une MatchException. Le compilateur y ajoute automatiquement une branche pour vous.

4.3. Guard avec when

Vous pouvez ajouter une condition à un pattern avec 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;
    };
}

Les transactions par carte à partir de 10 millions paient 1 % de frais, les autres 2 %. Une branche avec guard ne compte pas comme couvrant complètement CardPayment. Donc la branche case CardPayment c sans guard doit toujours exister.

Testons avec quelques transactions :

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

Résultat :

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

4.4. Ordre des cases et dominance

Le switch essaie les patterns de haut en bas. Si une branche générale vient avant une branche plus spécifique, la branche suivante ne sera jamais atteinte. Le compilateur détecte cette erreur :

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

Règle facile à retenir : placez les branches étroites (avec guard ou type enfant) d’abord, les branches larges ensuite.

4.5. Gérer null

Par défaut, un switch qui rencontre null lève une NullPointerException, comme avant. Pour gérer null explicitement, ajoutez case null :

public String label(Payment payment) {
    return switch (payment) {
        case null -> "Méthode non sélectionnée";
        case CardPayment c -> "Carte";
        case BankTransfer b -> "Virement";
        case EWallet w -> "Portefeuille " + w.provider();
    };
}

Vous pouvez aussi combiner case null, default -> quand le switch a une branche default.

5. Record patterns

Les record patterns permettent de destructurer les composants d’un record directement dans le case. Vous n’avez pas besoin d’appeler les accesseurs :

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

CardPayment(String number, long amount) correspond quand payment est un CardPayment, et assigne number et amount. Vous pouvez utiliser var pour que le compilateur déduise le type. Avec new CardPayment("4111-1111", 500_000), le résultat est Carte ****1111: 500000.

Le vrai pouvoir des record patterns réside dans la capacité à les imbriquer :

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 ->
                "À examiner : carte " + number;
        case Charge(Payment p) -> "Encaisser " + p.amount();
        case Refund(var original, var reason) -> "Rembourser " + original.amount() + " (" + reason + ")";
    };
}

La première branche correspond à un Charge contenant un CardPayment d’un montant élevé, en une seule ligne. Le compilateur vérifie toujours l’exhaustiveness pour les patterns imbriqués : Charge(Payment p) couvre tous les Charge, et Refund(...) couvre tous les Refund.

Note : Le pattern innommé _, par exemple case Refund(_, var reason), est seulement en preview en Java 21 et requiert --enable-preview. Cette fonctionnalité est officielle à partir de Java 22. Si votre projet en reste à Java 21, utilisez var avec un nom de variable normal.

6. Tests

Vous devez configurer Java 21 avec JUnit 5 et AssertJ. Si vous utilisez Spring Boot 3.x, spring-boot-starter-test contient déjà les deux. Avec un projet Maven pur :

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

Test pour FeeCalculator de la section 4.3 et describe de la section 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("Carte ****1111: 500000")
                .doesNotContain("4111-");
    }
}

Les tests doivent se concentrer sur les limites des guards (juste avant et après 10 millions), car c’est là que l’ordre des case a le plus de chances de se tromper. L’exhaustiveness, vous n’avez pas besoin de la tester : le compilateur s’en charge.

7. Erreurs courantes et quand ne pas utiliser

Ajouter default à un switch sur un sealed type. Le code compile toujours, mais vous perdez le principal avantage. Quand on ajoute un nouveau type enfant, il tombera silencieusement dans default au lieu de causer une erreur. Avec un sealed type, listez tous les cas et omettez default.

Oublier qu’une branche avec when ne couvre pas tout le type. Si chaque case CardPayment a un guard, le compilateur signalera qu’il manque un cas. Vous devez toujours avoir une branche sans guard, ou une branche plus générale, à la fin.

Utiliser switch à la place du polymorphisme partout. Si le comportement appartient vraiment à l’objet lui-même, une méthode abstraite dans la classe parent est souvent meilleure. Le pattern matching est plus approprié quand la logique est en dehors de la hiérarchie, par exemple calcul de frais, rendu, mappage vers un DTO, et vous ne voulez pas fourrer tout ça dans le record.

Note : Ne pas utiliser sealed classes quand :

  • Vous écrivez une bibliothèque ou un point d’extension (plugin) où les utilisateurs doivent pouvoir ajouter leurs propres types.
  • Cette classe est une entité JPA nécessitant du lazy loading. Hibernate crée des proxies en héritage, donc sealed ou final l’empêchera.
  • Les types enfants sont dispersés sur plusieurs modules. Sealed exige qu’ils soient dans le même module, ou le même package sans modules.

Sérialisation JSON. Jackson ne sait pas automatiquement comment désérialiser un Payment au bon type enfant juste parce que l’interface est sealed. Vous devez toujours configurer les informations de type, par exemple avec @JsonTypeInfo combiné avec @JsonSubTypes.

8. Conclusion

Les sealed classes vous permettent de déclarer explicitement qu’un type n’a qu’un ensemble fixe de variantes. Le pattern matching pour switch et les record patterns transforment cet ensemble en code concis, vérifiés par le compilateur pour une couverture complète. Vous pouvez utiliser le guard when pour ajouter des conditions, case null pour gérer explicitement null, et les patterns imbriqués pour aller en profondeur dans vos données.

Trois principes à retenir : omettez default quand vous faites un switch sur un sealed type, placez les branches étroites avant les larges, et utilisez sealed uniquement pour les hiérarchies que vous voulez vraiment fermer.

javajava-21sealed-classespattern-matchingswitch