Looking at Java 21: Switch Pattern Matching

 · 13 min
Mourizal Zativa on Unsplash

The switch control structure has quite an evolution lately since its inception. First, there were switch expressions (JEP 325, 354, 361). And now, we get pattern matching for switch statements and expressions!

The idea of pattern matching for switch constructs has been brewing since Java 17 (JEP 406) and got refined with each iteration of the JDK (JEP 406, 420, 427, 433). With Java 21, it’s finally here as a full-fledged (non-preview) feature.


Pattern Matching (in Java)

The technique of pattern matching is about testing an expression against certain characteristics. A variant of pattern matching you’re certainly familiar with is type pattern matching with the instanceof keyword:

java
if (anyObj instanceof String) {
    var str = (String) anyObj;
    ...
}

Matching the pattern alone is only half the work, usually, we still want to use the value as the actual type. That means we still need to cast the pattern-verified variable. Thanks to Java 16 and JEP 394, though, type pattern matching got a little easier:

java
// As of Java 16
if (anyObj instanceof String str) {
    ...
}

There’s no longer a need to declare a new variable and cast the original value after matching! That’s a great improvement, but how about checking against multiple patterns?

So far, the only option was using as many if-else as needed:

java
static String asStringValue(Object anyValue) {
    String result = "n/a";

    if (anyValue instanceof String str) {
        result = str;
    } else if (anyValue instanceof JSONObject json) {
        result = json.toCompactString();
    } else if (anyValue instanceof BigDecimal bd) {
        result = bd.toEngineeringString();
    } else if (anyValue instance Integer i) {
        result = Integer.toString(i);
    } else if (anyValue instanceof LocalDate ld) {
        result = ld.format(DateTimeFormatter.ISO_LOCAL_DATE);
    } else {
        ....
    }

    return result;
}

Even though the improved instanceof saves us some code, it’s still quite an eyesore in dire need of improvement. If you look at it closely, though, it has the resemblance of a switch construct, with multiple different “cases” to be handled. So instead of further improving instanceof in if-else statements, why not use a more fitting language feature instead?


Pattern Matching with switch

Looking at how switch works so far, it boils down to pattern matching based solely on the equality of its constant case variants. The resulting code is easier to follow than a multi-case if-else eyesore, but the available conditions are limited.

The next logical evolution would be to allow patterns to match values in a switch, not only constants. Thinking about it even further, you might want full-blown expression like any if conditions use. However, as Java is well-known for smaller, incremental, and most importantly, “as backward compatible as possible” evolution instead of “screw any old code, give me all the features” approach, it started with introducing pattern matching.

switch + instanceof

Like an instanceof in an if condition, a switch case can now type-check its value and creates a case-scoped variable:

java
static String asStringValue(Object anyValue) {
    
    return switch (anyValue) {
        case String str      -> str;
        case JSONObject json -> json.toCompactString();
        case BigDecimal bd   -> bd.toEngineeringString();
        case Integer i       -> Integer.toString(i);
        case LocalDate ld    -> ld.format(DateTimeFormatter.ISO_LOCAL_DATE);
        default              -> "n/a";
    };
}

Now that’s an improvement in readability and reasonability compared to the previous if-else monstrosity. And it’s way more optimizable behind the scenes, too. Where an if-else will most likely end up with an O(n) time complexity, even though the problem is usually O(1). As a switch construct is more well-defined and restricted, it’s inherently more optimizable, and might even reach O(1).

The simple addition of instanceof support creates simpler and more performant code, already. However, you might have already spotted the possible bug in the previous code: what about null?

switch + null

The atrocious if-else construct has one advantage over traditional switch behavior: it doesn’t require a null check.

Even though null is possibly any type, in the case of pattern matching with instanceof, it’s no type at all, so you will always fall through to the last else, or at least won’t trigger a NullPointerException. The downside, however, is that each if needs to be checked, which results in the previously mentioned optimization problems.

With Java 21, switch finally allows case null to be included:

java
static String asStringValue(Object anyValue) {
    return switch (anyValue) {
        case null       -> "n/a";
        case String str -> str;
        ...
    };
}

In good ol’ Java tradition, this behavior change is actually implemented in a backward-compatible fashion:

If a case null is present, it handles any incoming null value. If there’s no case null, the switch will still throw a NullPointerException as before, even if a default case is present.

This little addition removes the annoying null-check in front of almost any switch to make sure it won’t blow up and moves the handling where it belongs.


Handling More Refined Cases

Even though adding the instanceof keyword and null handling to the switch construct is already a quite welcomed improvement, it lacks ceratin functionality compared to pattern matching in other languages. Thankfully, Java 21 still has a few more improvements up its sleeve.

Multi-Cases

If a value has more than one value, we can already test against it by “falling through” cases:

java
static boolean itsTheWeekend(DayOfWeek dow) {
    return switch(dow) {
        case SATURDAY:
        case SUNDAY:
            yield true;

        default:
            yield false;
    };
}

This approach has two obvious downsides. First, you can use it with the new arrow syntax for switch expressions, and second, it’s a code smell in my opinion a possible future bug.

Instead, we can use multiple labels in a single case:

java
static boolean itsTheWeekend(DayOfWeek dow) {
    return switch(dow) {
        case SATURDAY, SUNDAY -> true;
        default               -> false;
    };
}

The intent of the code is now clearer than ever, without relying on fallthrough or repetitive code.

This works with any kind of value, and you can mix it up, too:

java
static String describe(DayOfWeek dow) {
    return switch(dow) {
        case TUESDAY          -> "Taco Tuesday";
        case WEDNESDAY        -> "Margarita Wednesday";
        case SATURDAY, SUNDAY -> "Party time!";
        default               -> "Boring work day...";
    };
}

One exception, or special case (pun intended), is the handling default label. Only case null can be combined with default, with the being the second one, as defined in the JSL §14.11.1:

java
static String asStringValue(Object anyValue) {
    return switch (anyValue) {
        case null, default -> "n/a";
        case String str    -> str;
        ...
    };
}

Combining multiple case labels is another great addition. But let’s think about what else could improve or simplify the decision-making on what case to perform?

Guard Clauses

Guard clauses are a way to further refined the base condition of a case. They’re appended to the label before the : (colon) or -> (arrow) and are separated by the when keyword: case <pattern> where <guard clause>.

Let’s look at how to make more precise decisions before guard clauses and the other improvements mentioned in this article, first.

Imagine you need to handle a Integer value that has to be handled depending on a few magic numbers and its sign:

java
Integer val = ...;

if (val == null) {
    // handle appropriatly
}

switch (vale) {
    case 23:
    case 42: { // Special case 1
        ...
        break;
    }
    case 0: { // Special case 2
        ...
        break;
    }
    default: {
        if (val > 0) { // handle positive numbers
            ... 
            break;
        }

        // actual default case
        ...
        break;
    }
}

There’s a lot going on here, creating a hard-to-follow mess of a switch construct. So let’s apply what we’ve seen so far:

java
Integer val = ...;

switch (val) {
    // null handling moved into switch
    case null -> ...;
    
    // Special case 1 combined:
    case 23, 42 -> ...;
    
    // Special case 2 unchanged
    case 0 -> ...;
    
    // default unchanged
    default -> {
        if (val > 0) {
            // handle positive numbers
            ...
            break;
        }

        // actual default case
        ...
    }
}

That’s an improvement and is a little easier to follow, but we can do even better by using a guard clause!

The clause itself is like any other expression used for conditionals. In this case, it would be where val > 0. However, there’s a little more to it.

Thus far, type patterns are the only patterns available to switch, so we need to include Integer i if we want to use a guard clause. This results in the following code:

java
Integer val = ...;

switch (val) {
    // previous cases are unchanged
    case null   -> ...;
    case 23, 42 -> ...;    

    // the default case splits into two:
    // 1. handle positive numbers
    case Integer i when i > 0 -> ...;
    
    // 2. handle all other numbers
    default -> ...;
}

In this particular example, it might look a little weird to include the type pattern before the guard clause. But requiring a pattern beforehand makes the case label, and therefore your code’s intentions, more explicit, and easier to use if there’s more than one type pattern matched.

The refinement of case labels with guard clauses gives us more freedom to express more dynamic and precise conditions, but they also lead to a new problem of no longer 100% perfect matches for each case regardless of their order.

Case Dominance

When case labels only consisted of constants, their declaration order didn’t affect the decision process at all. With pattern case labels, however, it’s now possible that more than one case label matches.

Take the String type for example. It matches both against case String s and case CharSequence cs. If two labels apply, which one should be chosen?

The previous simple best-fit approach no longer works here. That’s why Java decided to rely on simpler order-based semantics: first come, first serve.

In the case of the String example, that means we must match against String first, and CharSequence later, or else the compile won’t be happy:

java
static void validOrder(Object obj) {
    switch (obj) {
        case String s        -> System.out.println("String: " + s);
        case CharSequence cs -> System.out.println("CS length " + cs.length());
        default              -> { break; }
    }
}

static void invalidOrder(Object obj) {
    switch (obj) {
        case CharSequence cs -> System.out.println("CS length " + cs.length());
        case String s        -> System.out.println("String: " + s);
        default              -> { break; }
    }
    // ERROR:
    // this case label is dominated by a preceding case label
    //         case String s        -> System.out.println("String: " + s);
    //              ^------^

}

The reasoning here is simple. If a case label dominates another one, we’ve created unreachable code, because the second type pattern (String) is a subtype of the first one (CharSequence).

Thankfully, the compiler checks all case labels and prevents us from doing so. To compare it to another Java feature, dominance is analogous to multi-catch`` blocks and the order of caught Exceptions. Declaring catch` blocks that have no chance of ever being called just doesn’t make sense, and should be prevented.

In essence, the case labels need to be defined in most to least precise matching order, leading to more predictable and reasonable labels.

Here is the suggested order:

  1. Constants
  2. Guarded Patterns
  3. Unguarded Patterns

Let’s take a close look what that means code-wise.

An unguarded pattern ALWAYS dominates a guarded one with the same pattern

java
Object obj = ...;

// INVALID: first case always matches
//          before the second one has a chance to check
switch (obj) {
    case String s                   -> ...;
    case String s when !s.isEmpty() -> ...;
    default                         -> { break; }
}

Guarded Pattern CAN dominate a constant case label

java
Object obj = ...;

// INVALID: first case matches ALL Strings, so second
//          case is unreachable
switch (obj) {
    case String s -> ...;
    case "hello"  -> ...;
    default       -> { break; }
}

Guarded pattern matches unguarded on if when true

java
Object obj = ...;

// INVALID: this is a special case of a constant guard
//          clause using true
switch (obj) {
    case String s when true -> ...;
    case String s           -> ...;
    default                 -> { break; }
}

Pattern matches CAN dominate default case

java
String str = ...;

// INVALID: first case ALWAYS matches, no possible default
switch (str) {
    case String s -> ...;
    default       -> { break; }
}

What the Future Might Hold For Us

Depending on how you look at it, the changes to switch were merely incremental and lacks aspects of other languages, even ones available on the JVM. But they fixed certain pain points many of us had to deal with over the years, and opened the door for so many possibilities to simplify our code. That doesn’t mean, though, that it needs to end there.

JEP 441 also lists three possible future improvements, so let’s take a look!

Dealing with Primitives

Whenever we talk about Java and all the awesome new features that keep coming since Java 8, we also need to talk about how to handle primitives. As you might have guessed already from the chosen examples, primitives aren’t supported in type pattern, so no case int i -> ... yet.

The reasoning behind this is simple. To support primitives in type patterns, instanceof has to support it, first. Although it’s something that should be available, too, it makes more sense to release a feature like “Pattern Matching for switch” with the tools at hand, and in a timely manner, instead of waiting for other features to improve.

With all the work surrounding Project Valhalla, I’m sure supporting primitives in switch constructs will definitely be available in a future Java version.

Deconstruction patterns

A new type of pattern to deconstruct types in a specific way. Where type patterns result in a new variable of that type, a deconstruct pattern could result in multiple variables.

Think about a type hierarchy used for a calculator-like system. The base type would be Expr, with subtypes for values, negating, adding, etc. Depending on the type, there could be more than one inner value to work with:

java
// Some future Java
int eval(Expr n) {
     return switch (n) {
         case IntExpr(int i)              -> i;
         case NegExpr(Expr n)             -> -1 * eval(n);
         case AddExpr(Expr lhs, Expr rhs) -> eval(lhs) + eval(rhs);
         case MulExpr(Expr lhs, Expr rhs) -> eval(lhs) * eval(rhs);
         default -> throw new IllegalStateException();
     };
}

To implement such"ad-hoc polymorphic calculations" right now, we could use the visitor pattern. Compared to the quite straightforward and easy-to-reason-with switch construct, it’d be cumbersome, to say the least.

AND and OR Patterns

For more label expressiveness, AND and OR patterns would be a nice addition. However, a certain degree of combination is possible with multi-cases, or using && or || in guard clauses.


Conclusion

The language designers did what I believe is the best approach to improving a language: small incremental steps that feel like a natural evolution, instead of forcing a revolution upon us.

Let’s recapitulate what improvements JEP 441 introduced to Java:

  • null handling baked right into switch itself
  • Type pattern matching
  • Refine type pattern matching with guard clauses
  • Multi-case label

For me, that’s a lot of improvement that will be easy to use from the get-go and will result in more straightforward and easier-to-reason with switch statements and expressions.

Would I prefer to have the same power of Scala’s or Rust’s pattern matching available in Java? Absolutely!

Do I want to fix all my code because it just isn’t compatible with the previous way of doing things? Hell no!

That’s why I really enjoy the direction Java has taken over the last few years and where it’s heading. These little improvements accumulate to something bigger, in a familiar environment with the backward-compatibility we’re used to.


A Functional Approach to Java Cover Image
Interested in using functional concepts and techniques in your Java code?
Check out my book!
Available in English, Polish, and soon, Chinese.

Resources

Looking at Java 21