CodeWiz Logo

    CodeWiz

    Clean code with Pattern Matching in Java 23

    Clean code with Pattern Matching in Java 23

    27/10/2024

    Introduction

    Pattern matching allows you to test an object against a structure or pattern and if it matches that pattern, you can extract data from the object in a precise and robust way. This feature has been released in multiple versions of Java, and this blog will cover all the pattern matching related features released till Java 23.

    Implementing a Banking Transaction Handler

    Let's say we are implementing a banking transaction handler. Say we need to process different types of transactions which will come through an API or through a Kafka topic in our code. For example, we will receive messages for deposit, withdrawal, and transfer transactions. Let us see how we can model these transactions and handle them using pattern matching.

    Modeling Transactions with Records

    From Java 16, the best approach to model your data classes is to use records. Records are transparent, immutable carriers of data which you can use to create immutable objects. First, we define the Transaction interface and the records for different types of transactions.

    public class BankTransactionHandler {
    
        // Deposit, Withdrawal and Transfer
        public interface Transaction {}
        public record Deposit(String accountNumber, int amount) implements Transaction {}
        public record Withdrawal(String accountNumber, int amount) implements Transaction {}
        public record Transfer(String fromAccountNo, String toAccountNo, int amount) implements Transaction {}
    
        public void handleTransaction(Transaction transaction) {
        }
    }

    Handle Transactions with instanceof and Casting

    If we use data oriented programming and a traditional approach, we can handle transactions using instanceof checks and explicit casting.

    public void handleTransaction(Transaction transaction) {
        if (transaction instanceof Deposit) {
            Deposit deposit = (Deposit) transaction;
            System.out.printf("Handling deposit of %d to account %s%n", deposit.amount(), deposit.accountNumber());
        } else if (transaction instanceof Withdrawal) {
            Withdrawal withdrawal = (Withdrawal) transaction;
            System.out.printf("Handling withdrawal of %d from account %s%n", withdrawal.amount(), withdrawal.accountNumber());
        } else if (transaction instanceof Transfer) {
            Transfer transfer = (Transfer) transaction;
            System.out.printf("Handling transfer of %d from account %s to account %s%n", transfer.amount(), transfer.fromAccountNo(), transfer.toAccountNo());
        } else {
            throw new IllegalArgumentException("Unknown transaction type: " + transaction);
        }
    }

    Simplify with Pattern Matching for instanceof

    We can simplify the code using pattern matching for instanceof.

    public void handleTransaction(Transaction transaction) {
        if (transaction instanceof Deposit deposit) {
            System.out.printf("Handling deposit of %d to account %s%n", deposit.amount(), deposit.accountNumber());
        } else if (transaction instanceof Withdrawal withdrawal) {
            System.out.printf("Handling withdrawal of %d from account %s%n", withdrawal.amount(), withdrawal.accountNumber());
        } else if (transaction instanceof Transfer transfer) {
            System.out.printf("Handling transfer of %d from account %s to account %s%n", transfer.amount(), transfer.fromAccountNo(), transfer.toAccountNo());
        } else {
            throw new IllegalArgumentException("Unknown transaction type: " + transaction);
        }
    }

    Pattern matching will check if the transaction is of type like Deposit. If it satisfies this condition, it will safely cast the transaction and make it available as a local variable with the specified name.

    Use Pattern Matching with switch

    We can also use pattern matching with switch expressions to handle transactions more cleanly.

    public void handleTransaction(Transaction transaction) {
        switch (transaction) {
            case Deposit deposit ->
                    System.out.printf("Handling deposit of %d to account %s%n", deposit.amount(), deposit.accountNumber());
            case Withdrawal withdrawal ->
                    System.out.printf("Handling withdrawal of %d from account %s%n", withdrawal.amount(), withdrawal.accountNumber());
            case Transfer transfer ->
                    System.out.printf("Handling transfer of %d from account %s to account %s%n", transfer.amount(), transfer.fromAccountNo(), transfer.toAccountNo());
            case null, default -> throw new IllegalArgumentException("Unknown transaction type: " + transaction);
        }
    }

    When you use switch expression, you can return a value. For our scenario it is void, but you can also return like below. Also, you don't need to use break statement in each case.

    public String getTransactionDescription(Transaction transaction) {
        return switch (transaction) {
            case Deposit deposit -> String.format("Handling deposit of %d to account %s", deposit.amount(), deposit.accountNumber());
            case Withdrawal withdrawal -> String.format("Handling withdrawal of %d from account %s", withdrawal.amount(), withdrawal.accountNumber());
            case Transfer transfer -> String.format("Handling transfer of %d from account %s to account %s", transfer.amount(), transfer.fromAccountNo(), transfer.toAccountNo());
            case null, default -> throw new IllegalArgumentException("Unknown transaction type: " + transaction);
        };
    }
    

    When you use a switch expression, it is exhaustive. The compiler will check to ensure that all possible cases are covered. If you remove the default case, you will get a compile-time error. This check is not present in a traditional switch statement.

    Additionally, you can use multiple values in a single case by separating them with commas, a feature introduced recently - case null, default.

    Problem with using default case

    If someone adds a new type to the model, such as a FCYTransaction that implements Transaction, the switch expression will not handle it explicitly. Instead, it will fall through to the default case, and the error will only be identified at runtime.

    public record FCYTransfer(String fromAccountNo, String toAccountNo, int amount, String curr) implements Transaction {}

    Using Sealed Interfaces/Classes

    Wouldn't it be nice if the compiler could check that all possible cases are handled explicitly at compile time, and if we can remove the default case? That is where we can use sealed interfaces or classes. Sealed classes and interfaces restrict which classes can be subclasses. This way, we can ensure that all possible subclasses are handled explicitly. Once you seal the interface, you need to specify which classes can implement it using the permits keyword. Let's seal the Transaction interface and specify which classes can implement it.

    public sealed interface Transaction permits Deposit, Withdrawal, Transfer {}

    Since FCYTransfer is not included in the permits clause now, the compiler will give an error if you try to compile the FCYTransfer. This way, you can ensure that all possible subclasses are handled explicitly.

    Now let us add FCYTransfer also to the permits clause

    public sealed interface Transaction permits Deposit, Withdrawal, Transfer, FCYTransfer {}

    Now if we remove the default from the switch expression, the compiler will give an error as it is not exhaustive.

    But you dont need default case, if all cases in the permits clause are handled like below.

    public void handleTransaction(Transaction transaction) {
        switch (transaction) {
            case Deposit deposit ->
                    System.out.printf("Handling deposit of %d to account %s%n", deposit.amount(), deposit.accountNumber());
            case Withdrawal withdrawal ->
                    System.out.printf("Handling withdrawal of %d from account %s%n", withdrawal.amount(), withdrawal.accountNumber());
            case Transfer transfer ->
                    System.out.printf("Handling transfer of %d from account %s to account %s%n", transfer.amount(), transfer.fromAccountNo(), transfer.toAccountNo());
            case FCYTransfer fcyTransfer ->
                    System.out.printf("Handling foreign transfer of %d from account %s to account %s%n", fcyTransfer.amount(), fcyTransfer.fromAccountNo(), fcyTransfer.toAccountNo());
        }
    }

    Now if any new type is added to the permits clause, the compiler will give an error if it is not handled explicitly in the switch expression.

    Destructuring Patterns

    Destructuring patterns allow you to extract multiple values from an object in a single case. For example, you can extract the account number and amount from a deposit transaction in a single case. This is now only possible with records, but in future it will be extended to other types.

    switch (transaction) {
        case Deposit(String accNo, int amt) ->
                System.out.printf("Handling deposit of %d to account %s%n", amt, accNo);
        // other cases
    }

    Destructuring Patterns with Complex Records

    Now let's say we have more complex records with nested records. We can use destructuring patterns to extract values from these nested records.

    
    public record FCYTransfer(Account fromAccount, Account toAccount, int amount) implements Transaction {}
    
    public record Customer(String customerId, String name) {}
    public record Account(String accountNumber, Customer customer, String curr) {}
    switch (transaction) {
        // other cases
        case FCYTransfer(Account(String fromAcct, _, _),
                            Account(String toAcct, _, _), int amount) ->
                System.out.printf("Handling foreign transfer of %d from account %s to account %s%n", amount, fromAcct, toAcct);
    }
    

    In the above code, we are extracting the account number from the fromAccount and toAccount records and ignoring the customer and currency fields using unnamed patterns _.

    Guarded Patterns

    We can use guarded patterns to add additional conditions to our pattern matching cases. Say if withdrawal amount is greater than 10,000, we want to send out an alert.

    switch (transaction) {
        case Deposit(String accNo, int amt) ->
                System.out.printf("Handling deposit of %d to account %s%n", amt, accNo);
        case Withdrawal(String accNo, int amt) when amt > 10_000 ->
                // send out alert
                System.out.printf("Handling withdrawal of %d from account %s%n", amt, accNo);
        case Withdrawal(String accNo, int amt) ->
                System.out.printf("Handling withdrawal of %d from account %s%n", amt, accNo);
        // other cases
    }

    Please note that we have to add the same case twice, one with the guard and one without to make it exhaustive.

    Pattern Matching related change in Java 23

    Pattern matching based on records, classes, or wrapper objects was already available in Java 22. The new feature introduced in Java 23 is pattern matching for primitives. This allows you to perform type-based processing on primitive types. For example, if you have an object as input to a function, you can process it based on its type like below.

    public String getType(Object obj) {
        return switch (obj) {
            case byte b -> "Byte";
            case int i -> "Integer";
            case long l -> "Long";
            default -> "Unknown";
        };
    }   

    Conclusion

    Pattern matching in Java 23 provides a powerful way to simplify your code and enhance its readability. By using pattern matching with instanceof and switch expressions, destructuring patterns, guarded patterns, and sealed interfaces, you can write cleaner and more maintainable code.

    To stay updated with the latest updates in Java, follow us on YouTube and LinkedIn. You can find the code used in this blog here.

    Video Version

    To watch a more detailed video version of pattern matching in Java 23, see the video below: