Sealed classes and pattern matching for switch in Java 21

1. Overview

When a data type has only a few fixed variants—say, credit card, bank transfer, or e-wallet—you typically handle them with a chain of if (x instanceof ...). This approach is verbose. Worse, it’s easy to miss a case without anyone noticing.

Java 21 solves this with three features that work together: sealed classes (official since Java 17), pattern matching for switch (JEP 441), and record patterns (JEP 440). After reading this article, you’ll know how to model a closed set of types, write a switch that the compiler verifies covers all cases, and avoid common pitfalls.

2. The problem with the old approach

Suppose you need to calculate the fee for each payment type. Before Java 21, code typically looked like this:

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

The code above has three problems:

  • You forgot EWallet, but the code still compiles fine. The error only shows up at runtime.
  • The throw branch at the end is mandatory, even though it should never happen from a business logic perspective.
  • When adding a new payment type, you have to manually find every place with this if-chain and update it.

3. Sealed classes

3.1. Declaring a sealed interface

A sealed class or sealed interface lists exactly which classes are allowed to extend it:

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

The sealed keyword along with the permits clause tells the compiler that Payment has exactly three subtypes. If another class, say class Crypto implements Payment, tries to extend it, you’ll get a compile error.

If the subclasses are in the same file as the sealed class, you can omit permits. The compiler will infer the list.

3.2. Rules for subclasses

Each direct subclass of a sealed type must declare one of three modifiers:

Modifier Meaning
final No one can further extend it
sealed Continue restricting with its own permits clause
non-sealed Opens up for any class to extend

Records are final by default, so the example above doesn’t need anything extra. With regular classes, you must be explicit:

public sealed abstract class Notification permits EmailNotification, SmsNotification {}

public final class EmailNotification extends Notification {}

public non-sealed class SmsNotification extends Notification {}

// Valid because SmsNotification is non-sealed
public class ZaloNotification extends SmsNotification {}

Here, EmailNotification is fully locked down. By contrast, SmsNotification is open for anyone to extend. As a result, when you switch on Notification, the SmsNotification branch will also cover ZaloNotification.

3.3. Package and module constraints

Classes in the permits list must be in the same module as the parent class. If your project doesn’t use modules (unnamed module), they must be in the same package. This is by design: the author of the parent class controls the entire hierarchy.

4. Pattern matching for switch

4.1. Type pattern

Starting in Java 21, case labels can be a type pattern with a binding 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;
        };
    }
}

Each case both checks the type and binds a variable that’s already cast (c, b, w). The variable is scoped to just that branch. The code has no default clause and no throw statement.

4.2. Exhaustiveness: compiler checks all cases

Since Payment is sealed, the compiler knows exactly the three subtypes. If you delete the line case EWallet w -> 0;, the code won’t compile:

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

This is the biggest benefit. When you add QrPayment to permits, every switch on Payment in your codebase will error out until it’s updated. The compiler acts as your to-do list.

Note: If classes are compiled separately and a subtype appears at runtime that the switch doesn’t know about, the JVM will throw MatchException. The compiler inserts this branch for you.

4.3. Guard with when

You can add a condition to a pattern with 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;
    };
}

Card transactions of 10 million VND or more pay 1% fee, others pay 2%. A branch with a guard is not considered to cover all CardPayment cases. Therefore, the second case CardPayment c branch is still mandatory.

Running it with a few 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 + " -> fee " + calculator.fee(p)));
    }
}

Output:

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

4.4. Case order and dominance

switch matches patterns top-to-bottom. If a more general branch comes before a more specific one, the later branch will never be reached. The compiler catches this error:

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

Easy rule: put narrower branches (with guards or subtypes) first, broader branches later.

4.5. Handling null

By default, switch with null throws NullPointerException, same as the old behavior. If you want to handle null explicitly, you add case null:

public String label(Payment payment) {
    return switch (payment) {
        case null -> "No method selected";
        case CardPayment c -> "Card";
        case BankTransfer b -> "Bank transfer";
        case EWallet w -> "Wallet " + w.provider();
    };
}

You can also combine case null, default -> when your switch has a default branch.

5. Record patterns

Record patterns let you destructure a record’s components right in the case label. This way you don’t need to call accessors:

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

CardPayment(String number, long amount) matches when payment is a CardPayment and binds number and amount. You can use var to let the compiler infer the type. With new CardPayment("4111-1111", 500_000), the result is Card ****1111: 500000.

The real power of record patterns lies in nesting:

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 ->
                "Needs review: card " + number;
        case Charge(Payment p) -> "Charge " + p.amount();
        case Refund(var original, var reason) -> "Refund " + original.amount() + " (" + reason + ")";
    };
}

The first branch matches a Charge containing a CardPayment with a large amount, all in one line. The compiler still checks exhaustiveness for nested patterns: Charge(Payment p) covers all Charge cases, while Refund(...) covers all Refund cases.

Note: The unnamed pattern _, such as case Refund(_, var reason), is only a preview in Java 21 and requires --enable-preview. This feature is official in Java 22. If your project is stuck on Java 21, use var with regular variable names.

6. Testing

You’ll need Java 21 configured with JUnit 5 and AssertJ. If you’re using Spring Boot 3.x, spring-boot-starter-test already includes both. For a vanilla Maven project:

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

Tests for FeeCalculator from section 4.3 and describe from 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("Card ****1111: 500000")
                .doesNotContain("4111-");
    }
}

Tests should focus on guard boundaries—just below and above 10 million—since that’s where case order is most likely to be wrong. No need to test exhaustiveness: the compiler handles that.

7. Common mistakes and when not to use

Adding default to a switch on a sealed type. Code still compiles, but you lose the main benefit. When you add a new subtype, it silently falls through to default instead of causing a compile error. With a sealed type, list all branches and drop default.

Forgetting that a branch with when doesn’t cover the entire type. If every case CardPayment has a guard, the compiler will report missing cases. You always need a branch without a guard, or a more general branch, at the end.

Using switch instead of polymorphism everywhere. If the behavior truly belongs to the object itself, an abstract method in the parent class is still better. Pattern matching is better when the logic lives outside the hierarchy, like fee calculation, rendering, or mapping to DTOs, and you don’t want to cram everything into the record.

Note: Don’t use sealed classes when:

  • You’re writing a library or extension point (plugin) where users need to add their own subtypes.
  • The class is a JPA entity that needs lazy loading. Hibernate creates proxies by subclassing the entity, so sealed or final will break this mechanism.
  • The subtypes are spread across multiple modules. Sealed requires them in the same module, or same package if not using modules.

Serializing to JSON. Jackson doesn’t automatically know how to deserialize a Payment to the right subtype just because the interface is sealed. You still need to configure type information, for example, @JsonTypeInfo combined with @JsonSubTypes.

8. Conclusion

Sealed classes let you declare clearly that a type has only a fixed set of variants. Pattern matching for switch and record patterns turn that set into concise code, while the compiler verifies all cases are covered. You can use guard when to add conditions, case null to handle null explicitly, and nested patterns to dig into your data.

Three principles to remember: drop default when switching on a sealed type, put narrower branches before wider ones, and only use sealed for hierarchies that you truly want to close off.

javajava-21sealed-classespattern-matchingswitch