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
throwfinale 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
ifpour 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
switchne connaît pas, la JVM lèvera uneMatchException. 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 exemplecase 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, utilisezvaravec 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.