posts

A guide to pattern matching for switch in Java 21

Sep 5, 2024
Catherine Edelveis
22.2

Pattern matching for switch statements and expressions was introduced in JDK17 and refined in the following releases. This feature evolved together with record patterns included into JDK 19. Both features were finalized in Java 21, so let’s see how we can use them in development.

The article contains some theory on switch statements / expressions and pattern matching, so if you are already familiar with the concepts, feel free to jump to the practical section.

What are switch statements and expressions

The switch statements are statements with multiple execution paths. Using switch instead of traditional if-else statements helps to significantly reduce boilerplate code. So, for instance, instead of this code:

        String dayOfWeek = "Tuesday";
        String moodOfTheDay;

        if (dayOfWeek.equals("Monday")) {
            moodOfTheDay = "Ready to conquer the world";
        } else if (dayOfWeek.equals("Tuesday")) {
            moodOfTheDay = "In desperate need of a chocolate cake";
        } else if (dayOfWeek.equals("Wednesday")) {
            moodOfTheDay = "Exhausted";
        } else if (dayOfWeek.equals("Thursday")) {
            moodOfTheDay = "Exhausted";
        } else if (dayOfWeek.equals("Friday")) {
            moodOfTheDay = "Party hard";
        } else if (dayOfWeek.equals("Saturday")) {
            moodOfTheDay = "Where am I?";
        } else if (dayOfWeek.equals("Sunday")) {
            moodOfTheDay = "Watching the clouds pass by";
        } else {
            moodOfTheDay = "There's no such day";
        }

        System.out.println(moodOfTheDay);

We can write this:

        String dayOfWeek = "Tuesday";
        String moodOfTheDay;
        switch (dayOfWeek) {
            case "Monday":  moodOfTheDay = "Ready to conquer the world";
                break;
            case "Tuesday":  moodOfTheDay = "In desperate need of a chocolate cake";
                break;
            case "Wednesday":  moodOfTheDay = "Exhausted";
                break;
            case "Thursday":  moodOfTheDay = "Exhausted";
                break;
            case "Friday":  moodOfTheDay = "Party hard";
                break;
            case "Saturday":  moodOfTheDay = "Where am I?";
                break;
            case "Sunday":  moodOfTheDay = "Watching the clouds pass by";
                break;
            default: moodOfTheDay = "There's no such day";
                break;
        }
        System.out.println(moodOfTheDay);

A switch statement consists of a body in braces (switch block), which includes one or multiple labels followed by statements (code to be executed): the case labels containing values to which the switch expression is compared, and an optional default label, whose statement is executed in case the switch expression doesn’t match any value in the case labels.

The example above is the traditional fall-through switch statement with a case …: label. Fall-through means that all switch statements will be executed unless there’s a break clause in each. This semantics has long been an irritation point for developers as it is verbose and error-prone. So, Java 14 brought a new case … -> label that

1. Means that only one statement after the label matching the expression will be executed even without the break clause, and

2. Allows for multiple constants per case.

Therefore, the code above can be simplified to:

        String dayOfWeek = "Tuesday";
        String moodOfTheDay;
        switch (dayOfWeek) {
            case "Monday" -> moodOfTheDay = "Ready to conquer the world";
            case "Tuesday" -> moodOfTheDay = "In desperate need of a chocolate cake";
            case "Wednesday", "Thursday" -> moodOfTheDay = "Exhausted";
            case "Friday" -> moodOfTheDay = "Party hard";
            case "Saturday" -> moodOfTheDay = "Where am I?";
            case "Sunday" -> moodOfTheDay = "Watching the clouds pass by";
            default -> moodOfTheDay = "There's no such day";
        }
        System.out.println(moodOfTheDay);

Java 14 also introduced switch expressions that assigns a value to the given variable directly. So, we can simplify our example even more:

        String dayOfWeek = "Tuesday";
        String moodOfTheDay = switch (dayOfWeek) {
            case "Monday" -> "Ready to conquer the world";
            case "Tuesday" -> "In desperate need of a chocolate cake";
            case "Wednesday", "Thursday" -> "Exhausted";
            case "Friday" -> "Party hard";
            case "Saturday" -> "Where am I?";
            case "Sunday" -> "Watching the clouds pass by";
            default -> "There's no such day";
        };
        System.out.println(moodOfTheDay);

But despite the fact that switch expressions became more developer-friendly, they still possessed some limitations:

  • Support for a limited number of types: primitive types (int, byte, char, short, except for long), their boxed forms (Integer, Byte, Short, Character), enums, and String;
  • Special treatment of null values: separate code block outside of a switch statement / expression for handling nulls is required;
  • The switch expression can be tested against constants in case labels only for exact equality.

This is where pattern matching comes to rescue.

What is pattern matching

Pattern matching means testing an object against a particular structure and then extracting values from the object if it matches some structure. A pattern consists of a test applied to the target (the object that we check) and a set of pattern variables, which are extracted from the target only if the test passes successfully.

Pattern matching has already been used in regular expressions. But this feature was extended to the instanceof operator in JEP 394 for Java 16. Thanks to pattern matching for instanceof, instead of introducing a local variable, assigning the given expression, casting it to specific type, and only then processing the expression like this:

        Object myObject = "This is a String";
        if (myObject instanceof String) {
            String s = (String) myObject;
            System.out.println(s.toUpperCase());
        }

We can simply write this:

        Object myObject = "This is a String";
        if (myObject instanceof String s) {
            System.out.println(s.toUpperCase());
        }

Where String s is a type pattern, which is a pattern that takes type as a test and contains one pattern variable, to which the target is assigned.

In further JDK releases, the scope of pattern matching was extended to switch statements / expressions to overcome their limitations and avoid boilerplate with multiple if-else statements.

How to use pattern matching for switch

Prerequisites:

Patterns in case labels

The syntax of a switch statement / expression doesn’t change, only now we use type patterns in case labels, plus we can use any type to test against as opposed to the traditional switch:

    static String patternTypes(Object myObject) {
        return switch (myObject) {
            case Integer i -> "This is an Integer: " + i;
            case Long l -> "This is a Long: " + l;
            case Dog d -> "This is a dog that makes " + d.voice();
            case ArrayList a -> "This is an ArrayList of size " + a.size();
            default -> "This is some other object: " + myObject.getClass();
        };
    }

    public static class Dog {
        String voice = "Woof!";
        public String voice() {
            return voice;
        }
    }

Pattern matching with the null case label

What about the null values? How can we handle them? There’s no need to carry the logic of testing against null outside the switch block, we can now do that inside the block using a case null label:

    static String patternTypes(Object myObject) {
        return switch (myObject) {
            case Integer i -> "This is an Integer: " + i;
            case Long l -> "This is a Long: " + l;
            case Dog d -> "This is a dog that makes " + d.voice();
            case ArrayList a -> "This is an ArrayList of size " + a.size();
            case null -> "You gave me a null value";
            default -> "This is some other object: " + myObject.getClass();
        };
    }

Note that if you don’t add the null case, switch will throw a NullPointerException like it always has.

Guarded pattern case labels and a when clause

What if we need to further test the value when it matches some pattern? For instance, we want to accept only Integers of certain values. One way is to use nested if-else statements, but it leads to redundant and potentially error-prone code.

Luckily, we can use guarded pattern case labels that contain a type pattern and a boolean expression or guard.

    static String guardedPatterns (Object myObject) {
        return switch (myObject) {
            case Integer i when i == 1 -> "Connecting you to the Manager...";
            case Integer i when i == 2 -> "Connecting you to the Sales Department...";
            default -> "Invalid input";
        };
    }

How to order case labels

Let’s linger on the previous example for a while. We can add a condition that checks whether the given object is an Integer of any value rather than 1 or 2:

    static String guardedPatterns (Object myObject) {
        return switch (myObject) {
            case Integer i when i == 1 -> "Connecting you to the Manager...";
            case Integer i when i == 2 -> "Connecting you to the Sales Department...";
            case Integer i -> "Invalid number";
            default -> "Totally invalid input!";
        };
    }

The code functions correctly, but what if we change the case order and put case Integer i -> on top?

    static String guardedPatterns (Object myObject) {
        return switch (myObject) {
            case Integer i -> "Invalid number";
            case Integer i when i == 1 -> "Connecting you to the Manager...";
            case Integer i when i == 2 -> "Connecting you to the Sales Department...";
            default -> "Totally invalid input!";
        };
    }

In this case, you will get a compile-time error java: this case label is dominated by a preceding case label.

What went wrong?

If we place case Integer i -> before case Integer i when i == 1, the first pattern case will dominate the second pattern case, meaning that any Integer matches the more general condition, and so the switch won’t go any further to analyze more specific cases and execute this statement. Therefore,

  • A more general (unguarded) pattern case label dominates the guarded case label with the same condition;
  • A pattern case label dominates the constant case label,
  • A type of a pattern case label dominates the subtype of a pattern case label.

So, for instance, placing a parent class in a pattern case over a child class like that

    static String patternOrder(Object myObject) {
        return switch (myObject) {
            case Animal a -> "This is an animal that makes " + a.voice();
            case Dog d -> "This is a dog that makes " + d.voice();
            default -> "This is not an animal";
        };
    }

    public static class Animal {
        String voice = "a random sound";
        public String voice() {
            return voice;
        }
    }

    public static class Dog extends Animal {
        String voice = "woof!";
        public String voice() {
            return voice;
        }
    }

Will also result in a compile-time error.

To conclude this section, there’s a simple rule to ordering pattern case labels: move down from more specific to more general conditions. Constant case labels should come first, then guarded pattern case labels, then unguarded pattern case labels. Subtypes should precede types.

Exhaustiveness of switch statements and expressions

Exhaustiveness of switch statements / expressions implies that all possible values of the selector expression must be handled in the switch block.

Let’s take one of the snippets we created above. If we remove the default case like that

    static String patternTypes(Object myObject) {
        return switch (myObject) {
            case Integer i -> "This is an Integer: " + i;
            case Dog d -> "This is a dog that makes " + d.voice();
            case ArrayList a -> "This is an ArrayList of size " + a.size();
        };
    }

it will result in compile-time error java: the switch expression does not cover all possible input values.

This functionality is especially useful with enums and sealed classes. Consider the following enum class:

enum DeliveryStatus { IN_TRANSIT, DELIVERED, CANCELED }

If we want to use a switch statement / expression with our enum, we can specify all possible input values without the default clause, and leave exhaustiveness checks to the compiler.

    static String exhaustiveSwitch(DeliveryStatus status) {
        return switch(status) {
            case IN_TRANSIT -> "Order is on its way";
            case DELIVERED -> "Order successfully delivered";
            case CANCELED -> "Order canceled";
        };
    }

Removing one of the case labels will lead to a compile-time error. There’s logic to that. Suppose we extend our enum with additional values. Without the requirement for exhaustiveness, we could get away with not adding new values to switch, which will result in undesired application behavior.

So, if you add new constants to your enum class without recompiling the class containing the switch expression, the compiler will throw an exception. 

The same applies to sealed classes and interfaces that restrict what other classes or interfaces can extend or implement them. For example, we have a sealed class Vehicle that lets only Motorcycle, Car, and Van extend it:

    public static abstract sealed class Vehicle permits Motorcycle, Car, Van {
        int maxSpeed;
        double maxPermittedWeight;

        abstract String printInfo();
    }

    public static final class Motorcycle extends Vehicle {
        int maxSpeed = 140;
        double maxPermittedWeight = 350.5;

        @Override
        String printInfo() {
            return "max speed: " + maxSpeed
                    + "\nmax permitted weight: " + maxPermittedWeight;

        }
    }

    public static non-sealed class Car extends Vehicle {
        final int maxSpeed = 137;
        final double maxPermittedWeight = 850.0;
        final String fuel = "petrol";

        @Override
        String printInfo() {
            return "max speed: " + maxSpeed
                    + "\nmax permitted weight: " + maxPermittedWeight
                    + "\nfuel: " + fuel;
        }
    }

    public static final class Van extends Vehicle {
        int maxSpeed = 111;
        double maxPermittedWeight = 1543.0;
        final int passengerSeats = 10;

        @Override
        String printInfo() {
            return "max speed: " + maxSpeed
                    + "\nmax permitted weight: " + maxPermittedWeight
                    + "\npassenger seats: " + passengerSeats;
        }
    }

We now have to cover all classes permitted by Vehicle in our switch expression.

    static String exhaustiveSwitchWithSealed(Vehicle vehicle) {
        return switch (vehicle) {
            case Motorcycle m -> m.printInfo();
            case Car c -> c.printInfo();
            case Van v -> v.printInfo();
        };
    }

But there’s a catch. If you remove one of the input values (here or in the enum example), and instead, add a default clause like that

    static String exhaustiveSwitchWithSealed(Vehicle vehicle) {
        return switch (vehicle) {
            case Motorcycle m -> m.printInfo();
            case Car c -> c.printInfo();
            default -> "No such vehicle";
        };
    }

The code will compile and run without errors. So, the general recommendation would be to avoid using default clauses in switch statements / expressions working with enums and sealed classes.

Before we move on to another section, I would like to highlight another possible pitfall related to enums in switch.

If you use a traditional switch statement with case …: labels, and don’t specify all existing enum constants:

    static String nonExhaustiveSwitch(DeliveryStatus status) {
        String response = "";
        switch (status) {
            case IN_TRANSIT:
                response = "Order is on its way";
                break;
            case DELIVERED:
                response = "Order successfully delivered";
                break;
        }
        return response;
    }

The code will also compile and run without errors. In case you use Intellij IDEA, Nicolai Parlog offered a workaround to this issue. Go to Settings -> Editor -> Inspections -> Enum ‘switch’ statement that misses case, and set the severity level to Error. Apply the changes. After that, you will see that the incomplete switch statement is highlighted by the IDE.

Using record patterns with switch

Record patterns combine the power of Java records (classes that act as immutable data carriers) and pattern matching.

A record pattern includes a record class type and a pattern list matched against the record component values.

For instance, we have a record Rectangle:

record Rectangle(double a, double b) { }

Then the corresponding record pattern will be Rectangle(double a, double b). Now, we can use this record pattern with the instanceof operator: without creating local variables and extracting the components explicitly, we can access the value components directly:

    static double getArea(Object shape) {
        if (shape instanceof Rectangle(double a, double b)) {
            return a * b;
        } else throw new IllegalArgumentException("Unknown shape");
    }

In a simple case without complex hierarchies, record patterns in switch statements / expressions help us to write very laconic code:

    static double switchWithRecordPattern(Object shape) {
        return switch (shape) {
            case Rectangle(double a, double b) -> a + b;
            case Square(double a) -> a * a;
            default -> throw new IllegalStateException("Unexpected value: " + shape);
        };
    }

    record Rectangle(double a, double b) { }
    record Square(double a) { }

In case you have a more complex situation with hierarchy involved:

    class Tesla {}
    class ModelY extends Tesla {}
    record Selection<T> (T first, T second) { }

You must make sure that the switch statement / expression is exhaustive, i.e., all possible combinations of the component pattern types must be covered:

    static String switchRecordExhaustive(Selection<Tesla> car) {
        return switch (car) {
            case Selection<Tesla> (ModelY modelY1, ModelY modelY2) -> "ModelY and ModelY";
            case Selection<Tesla> (Tesla tesla, ModelY modelY) -> "Tesla and ModelY";
            case Selection<Tesla> (ModelY modelY, Tesla tesla) -> "ModelY and Tesla";
            case Selection<Tesla> (Tesla tesla1, Tesla tesla2) -> "Tesla and Tesla";
        };
    }

Conclusion

 Pattern matching for switch is a useful feature that makes the code more concise, expressive, and reliable.

Subscribe to our newsletter if you want to learn about other cool features in newer Java versions!

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading