Java 21 - Pattern Matching for switch

14 minute read Published: 2023-11-10

Java has now extended the pattern matching feature to switch statements. This allows an expression to be tested against a number of patterns, each for a specific action. It enables complex data-oriented queries to be expressed concisely and safely.

This new feature has co-evolved with Record Patterns feature (JEP 440), it proposes to finalize the feature with additional small refinements that are based upon continued experience and feedback. The main changes from the previous JEP are:

Introduction

The goals of the JDK enhancement proposal were to expand the epressiveness and applicability for switch expressions and statements by allowing patterns to be used in case labels. It allows for historical null-hostility of switch to be more easy when desired.

It also increases the safety of switch statements by requiring that the pattern switch statements will cover all the possible input values. As well as ensuring that all exisiting switch expressions and statements will continue to compile with zero changes and execute with identical semantics.

Unfortunately prior to Java 21, switch was very limited. There was only possible to switch on values of a few types. The corresponding boxed forms, enum types, String (excluding long) and we could only test for exact equality against constants. We might want to use patterns to test the same variable against a number of different possibilities and by taking actions on each one of them, but since the early switch didn't support it, we end up with a chain of if/else.

A working example prior to Java 21 would be:

static String formatter(Object obj) {
    String formatted = "unknown";

    if (obj instanceof Integer i) {
        formatted = String.format("int %d", i);
    } else if (obj instanceof Long l) {
        formatted = String.format("Long %d", l);
    } else if (obj instanceof Double d) {
        formatted = String.format("Double %f", d);
    } else if (obj instanceof String s) {
        formatted = String.format("String %s", s);
    }

    return formatted;
}

This might feel familiar, it benefits from using instanceof expression pattern. But it's not good enough. This approach allows for possible coding errors to be remained hidden since we are using an overly general control construct. But using a switch is a perfect match for pattern matching.

We can re-write all of the code above to a more clearly an reliable piece of code since extending switch statements and expressions to work on any type allows for case labels with patterns rather than just constants.

A working example with Java 21 would be:

static String formatterPatternSwitch(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("int %d", i);
        case Long l    -> String.format("long %d", l);
        case Double d  -> String.format("double %f", d);
        case String s  -> String.format("String %s", s);
        default        -> obj.toString();
    }; 
}

As you can see, the switch semantics are very clear. A case label with a pattern applies if the value of the selector expression obj matches the pattern.

We also see that the intent of the code is much clearer since we are using the right control construct. As a bonus, this code is more optimizable, we are likely to be able to perform the dispatch in O(1) time.

Switches and null

Using switch statements and expressions traditionally throw NullPointerException if the selector expression evaluates to null, so the testing for null was be done outside of the switch.

A working example prior to Java 21 would be:

static void testFooBar(String s) {
    if (s == null) {
        System.out.println("Ouch!");
        return;
    }
    switch (s) {
        case "Foo", "Bar" -> System.out.println("Super");
        default           -> System.out.println("OK");
    }
}

This was a reasonable approach when switch supported only a few reference types. But, if switch allows a selector expression of any reference type and case labels that can have type patterns. In that case, the standalone null test feels like an arbitrary dinstiction that invites a needless boilerplate and opportunities for errors.

The perferable way is to integrate the null test into the switch by allowing a new null case label.

A working example with Java 21 would be:

static void testFooBar(String s) {
    switch (s) {
        case null         -> System.out.println("Ouch");
        case "Foo", "Bar" -> System.out.println("Super");
        default           -> System.out.println("OK");
    }
}

We can always determine null by using it as a case label in the switch. Without a case null, the switch throws NullPointerException, just as in previous Java versions. To keep backward compatibility with the current semantics of switch, the default label do not match a null selector.

Case refinement

By constrast to case labels with constants, we can apply many values by using a pattern case label.

A working example with Java 21 would be:

static void testString(String response) {
    switch (response) {
        case null -> { }
        case String s -> {
            if (s.equalsIgnoreCase("YES"))
                System.out.println("You got it");
            else if (s.equalsIgnoreCase("NO"))
                System.out.println("Shame");
            else
                System.out.println("Sorry?");
        } 
    }
}

By using a single pattern to discriminate among cases is a problem since it does not scale beyond a single condition. It would be preferable if we would write multiple patterns, but then we need a way to express a refinement to a pattern. We therefor allow when clauses in a switch block to specify guards for pattern case labels.

A working example with Java 21 that we would refer to as guarded case label would be:

String s when s.equalsIgnoreCase("YES")

By using that approach we can re-write our existing testString method which will lead to a more readable way:

static void testString(String response) {
    switch (response) {
        case null -> { }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {
            System.out.println("Sorry?");
        }
    }
}

There is a way we can further enhance this code by some extra rules for other constant strings:

static void testString(String response) {
    switch (response) {
        case null -> { }
        case "y", "Y" -> {
            System.out.println("You got it");
        }
        case "n", "N" -> {
            System.out.println("Shame");
        }
        case String s
        when s.equalsIgnoreCase("YES") -> {
            System.out.println("You got it");
        }
        case String s
        when s.equalsIgnoreCase("NO") -> {
            System.out.println("Shame");
        }
        case String s -> {
            System.out.println("Sorry?");
        } 
    }
}

Switches and enum constants

To use enum constants in case labels is highly constrained at the moment. We need to have an enum type for the selector expression in the switch, it also has to be simple names of the enum constants.

A working example prior to Java 21 would be:

public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }

static void testforHearts(Suit s) {
    switch (s) {
        case HEARTS -> System.out.println("It's a heart!");
        default -> System.out.println("Some other suit");
    }
}

Even if we are adding pattern labels this constraint will lead to unnecessarily verbose code.

A working example with Java 21 would be:

sealed interface CardClassification permits Suit, Tarot {}
public enum Suit implements CardClassification { CLUBS, DIAMONDS, HEARTS, SPADES }
final class Tarot implements CardClassification {}

static void exhaustiveSwitchWithoutEnumSupport(CardClassification c) {
    switch (c) {
        case Suit s when s == Suit.CLUBS -> {
            System.out.println("It's clubs");
        }
        case Suit s when s == Suit.DIAMONDS -> {
            System.out.println("It's diamonds");
        }
        case Suit s when s == Suit.HEARTS -> {
            System.out.println("It's hearts");
        }
        case Suit s -> {
            System.out.println("It's spades");
        }
        case Tarot t -> {
            System.out.println("It's a tarot");
        } 
    }
}

If we would have a seperate case for each enum constant, we could make this more readable rather than lots of guarded patterns. We could then re-write the above code as:

static void exhaustiveSwitchWithBetterEnumSupport(CardClassification c) {
    switch (c) {
        case Suit.CLUBS -> {
            System.out.println("It's clubs");
        }
        case Suit.DIAMONDS -> {
            System.out.println("It's diamonds");
        }
        case Suit.HEARTS -> {
            System.out.println("It's hearts");
        }
        case Suit.SPADES -> {
            System.out.println("It's spades");
        }
        case Tarot t -> {
            System.out.println("It's a tarot");
        }
    }
}

Now there is a direct case for each of the enum constants without using a guarded type pattern.

Enhanced type checking

Selector expression typing

By supporting patterns in a switch it now means that we can have a relaxed restriction on the type of the selector expression. Currently, the type of the selector expression of a regular switch must be either an integral primitive type (exluding long), the corresponding boxed form such as Character, Byte, Short, Integer, String, or an enum type.

For example, in the pattern switch below, the selector expression obj is matched with type patterns that involves a class type, enum type, record type and an array type. As well as a null case label and default.

A working example with Java 21 would be:

record Point(int i, int j) {}
enum Color { RED, GREEN, BLUE; }

static void typeTester(Object obj) {
    switch (obj) {
        case null     -> System.out.println("null");
        case String s -> System.out.println("String");
        case Color c  -> System.out.println("Color: " + c.toString());
        case Point p  -> System.out.println("Record class: " + p.toString());
        case int[] ia -> System.out.println("Array of ints of length" + ia.length);
        default       -> System.out.println("Something else");
    } 
}

In the switch block, for every case label, must be compatible with the selector expression. A pattern label (a case label with a pattern), we use the existing notion of compability of an expression with a pattern.

Dominance of case labels

Supporting pattern case labels means that for a given value of the selector expression it will be possible now to use more than one case label to apply, whereas in the earlier Java versions we could have at most one case label to be applied.

One example would be if the selector expression should evaluate to a String, then both case labels case String s and case CharSequence cs would apply.

An example with Java 21 would be:

static void first(Object obj) {
    switch (obj) {
        case String s ->
            System.out.println("A string: " + s);
        case CharSequence cs ->
            System.out.println("A sequence of length " + cs.length());
        default -> {
            break; 
        }
    }
}

The value of obj in this example if it's of type String, then it will apply the first case label. If it would be of type CharSequence, but not the type String then the second pattern label would apply. But let's say we swap the order of these two case labels?

A working example with Java 21 would be:

static void first(Object obj) {
    switch (obj) {
        case CharSequence cs ->
            System.out.println("A sequence of length " + cs.length());
        case String s ->    
            System.out.println("A string: " + s);
        default -> {
            break; 
        }
    }
}

If the value of obj now would be of type String the CharSequence case label applies. This is because it appears first in the switch block. The String case label is unreachable since there is no value of the selector expression that could cause it to be chosen.

Exhaustiveness of switch expressions and statements

Type coverage

By using a switch expression it requires that all possible values of the selector expression will be handled in the switch block. So to put it to another words, it must be exhaustive. For normal switch expressions, this property is enforced by a set of extra conditions on the switch block.

For pattern switch expressions and statements, we achieve this by defining a notion of type coverage of switch labels in a switch block.

A erroneous example with Java 21 would be:

static int coverage(Object obj) {
    return switch (obj) {           
        case String s -> s.length();
    };
}

This switch block only have one switch label, case String s. This pattern switch expressions is not exhaustive because of the type coverage of its switch block does not include the type of the selector expression (Object).

Exhaustiveness in practice

A erroneous example prior to Java 21 would be:

enum Color { RED, YELLOW, GREEN }

int numLetters = switch (color) {
    case RED -> 3;
    case GREEN -> 5;
}

This switch expression over an enum class is not exhaustive since the anticipated input YELLOW is not covered. By adding a case label to handle the YELLOW enum constant is sufficient to make the switch exhaustive, as expected.

A working example prior to Java 21 would be:

int numLetters = switch (color) {
    case RED -> 3;
    case GREEN -> 5;
    case YELLOW -> 6;
}

Exhaustiveness and sealed classes

If the type of the selector expression is a sealed class, then the type coverage check can take into account the permits clause of the sealed class to determine if a switch block is exhaustive or not.

Sealed classes and interfaces restrict which other classes or interfaces may extend or implement them.

By using a sealed class as the selector expression, we can sometimes remove the need for a default clause, which is a good practice.

An example with Java 21 would be:

sealed interface S permits A, B, C {}

final class A implements S {}
final class B implements S {}

record C(int i) implements S {}

static int testSealedExhaustive(S s) {
    return switch (s) {
        case A a -> 1;
        case B b -> 2;
        case C c -> 3;
    };
}

The compiler can now determine that the type coverage of the switch block is the types A, B and C. Since S, the type of the selector expression is a sealed interface whose permitted subclasses are exactly A, B and C, this switch block is now exhaustive, and thus no need of using a default label clause.

Dealing with null

A switch traditionally throws NullPointerException if the selector expression evaluates to null. There are, however, reasonable and non-exception-raising semenatics for pattern matching and null values, so we can treat null in a more regular way and still remain compatible with existing semantics.

In this new Java version, there is a new null case label. We should however know that:

An example with Java 21 would be:

static void nullMatch(Object obj) {
    switch (obj) {
        case null     -> System.out.println("null!");
        case String s -> System.out.println("String");
        default       -> System.out.println("Something else");
    }
}

With this given code example, we evaluate null, and print out null! instead of throwing a NullPointerException.

An example with Java 21 that can throw NullPointerException would be:

static void nullMatch(Object obj) {
    switch (obj) {
        case String s  -> System.out.println("String: " + s);
        case Integer i -> System.out.println("Integer");
        default        -> System.out.println("default");
    }
}

Errors

For example, by matching a value against a record pattern, the record's accessor method can complete abruptly. In this case where we match against a record pattern, pattern matching is defined to complete abruptly by throwing a MatchException. It will also complete abruptly if such pattern appears as a label in a switch block by throwing MatchException.

If no label in a pattern switch would match the value of the selector expression, then the switch completes abruptly and throws an MatchException, since it needs to be exhaustive.

An example with Java 21 that throws an exception would be:

record R(int i) {
    public int i() {
        return i / 0;
    } 
}

static void example(R r) {
    switch(r) {
        case R(var i): System.out.println(i);
    }
}

The invocation example(new R(42)) will cause a MatchException to be thrown.

A second example with Java 21 that throws an exception would be:

static void example(Object obj) {
    switch (obj) {
        case R r when (r.i / 0 == 1): System.out.println("It's an R!");
        default: break;
    }
}

This will throw a ArithmeticException.

Summary

So now you probably know a little bit more about Pattern Matching for switch. This new feature is very useful as you probably have realized until now comparing to older Java versions.

If you found it valuable, please consider sharing it, as it might also be valuable to others. Let me know if you have any questions by reaching me on š¯•¸!

Resources

Connect withĀ me