Contents

Java 21: switch the power on

Jacek Kunicki

02 Oct 2023.10 minutes read

Java 21: switch the power on webp image

The updated switch expression (not a statement anymore) has already been around since Java 14. However, it only allowed you to match on constant values. Java 21 introduces pattern matching for switch, which takes the switch to a whole new level.

In this article, I’m going to walk you through the new features and the power that the new switch gives you when combined with record types and sealed type hierarchies. Let’s go!

The fundamentals

Let’s start with a quick recap of what the switch expression is capable of. Have a look at the first example, and we’ll then go through the important parts:

enum Direction {
    NORTH, SOUTH, EAST, WEST;

    int bearing() {
        return switch (this) {                          // (1)
            case NORTH -> 0;                            // (2), (3)
            case SOUTH -> 180;
            case EAST -> 90;
            case WEST -> {
                System.out.println("Go west!");
                yield 270;                              // (4)
            }
        };
    }
}

Expression with arrow labels

If you noticed the subtle difference in naming, i.e., “statement” vs. “expression” – this is no coincidence. Before Java 14, switch was just a statement, or a control structure, while the new switch is an expression, i.e., it returns a value and thus can be assigned to a variable or returned inline as in (1).

The updated switch uses some new syntax – with so-called “arrow labels” (2), which can be followed by a single expression or a block. A single expression on the right-hand side of the arrow (3) is the value you want to return (remember that it’s an expression now). If you need a block with multiple statements on the right-hand side of the arrow, you have to use the new yield keyword (4).

No fall-through

Did you notice the lack of break statements? They are not needed anymore when using the arrow labels. There’s no fall-through by default, which means that the first matched case terminates the evaluation – which also means that the order matters, i.e., the more specific cases need to go first.

Exhaustiveness check

Try commenting out case NORTH -> 0; – what happens? The compiler should start complaining:

java: the switch expression does not cover all possible input values

This is thanks to the exhaustiveness check, i.e., the compiler making sure that the switch expression covers all possible values – so that you can avoid surprises at runtime. Plus, you don’t need to use a default branch “just in case”, since once it compiles, you know that all the cases are covered.

You are still free to use the default case to work around this compilation error, but due to the compile-time check, this needs to be an informed decision.

The nice thing about the exhaustiveness check is that it doesn’t only work with enums – more on this later.

Pattern matching

Java 21 introduces pattern matching – a powerful mechanism used by many programming languages (Scala, Haskell or Rust – to name a few) that lets you inspect the structure of an object instead of only looking at its value. This can be further used to extract data from nested object structures and simultaneously checking if certain conditions are met.

Extract and check the data

Consider the following example:

public record Point(int x, int y) {
}

class Cartesian {

    static void printQuadrant(Point p) {
        switch (p) {
            case Point(var x, var y) when x > 0 && y > 0 -> 
                System.out.println("first");
            default -> 
                System.out.println("other");
        }
    }
}

You must have noticed that there’s a lot of new things happening in this line:

case Point(var x, var y) when x > 0 && y > 0 ->

What we’re doing here is:

  • checking if the value we match on is of type Point,
  • extracting the values of the x and y fields so that they are accessible without referring to a Point instance (notice how the compiler is able to infer the types for x and y, which lets you use var instead of the actual type),
  • checking if the point coordinates satisfy a certain condition using the new when keyword.

If you wanted to achieve a similar result with an older version of Java, it would be something like:

if (p instanceof Point) {
    Point point = (Point) p;

    if (p.x() > 0 && p.y() > 0) {
        System.out.println("first");
    } else {
        System.out.println("other");
    }
}

I’d argue that the pattern matching syntax is much more concise, isn’t it?

It’s worth noting that pattern matching can not only be used with the switch expression, but also with the instanceof keyword. Thus in Java 21 you can still use instanceof for this example, yet in a much shorter version:

if (p instanceof Point(var x, var y) && x > 0 && y > 0) {
    // ...
}

Note that in this case, there’s no when keyword, but otherwise, the pattern matching, extraction, and condition check are similar to what you saw in the switch approach.

Migrate to Java 21. Partner with Java experts to modernize your codebase, improve developer experience and employee retention. Explore the offer >>

Null labels

What happens if the Point p passed to printQuadrant is null? You’re right – a NullPointerException is thrown. This is also the case in Java 21, but now you have a convenient way to handle the null case explicitly by writing case null -> – which lets you avoid the NPE.

Note that in the following code:

String test = null;

switch (test) {
    case String s -> System.out.println("String");
    case null -> System.out.println("null");
}

it’s still the null label that is going to match, even though the type of s is String.

Unleash the power of switch and pattern matching

So far, we went through some basic examples of how the switch expression and pattern matching work. However, the true power of those is revealed once you start handling more complex data types.

Errors as values

For the next example, let’s jump on a journey to the world of functional programming, where everything is a value – and so are the errors. With this in mind, let’s avoid using exceptions to signal errors but rather model the errors as values.

One of the benefits of such an approach is that you’re using a single mechanism (return value) to represent both the successful and erroneous behavior of your program instead of using a different mechanism for each scenario (return value or exceptions). For a broader rationale behind such an approach to error handling, I recommend you to read Tomasz Słabiak’s article on Functional Error Handling with Java 17.

Since an operation may result either in an error or in a success (then returning a value), let’s use a dedicated type to model a result:

public sealed interface Result<E, A> {

    record Success<E, A>(A value) implements Result<E, A> {
    }

    record Failure<E, A>(E error) implements Result<E, A> {
    }
}

In the world of functional programming, this is called a disjoint union or a coproduct type – one of the common Algebraic Data Types (ADTs) – which effectively means that a Result is either a Success or a Failure. If you used Vavr and their Either, this is exactly it, just under a different name.

Did you notice the sealed keyword in the interface declaration? You’re going to see shortly how it enables the compile-time exhaustiveness check in pattern matching.

What about the error type E in our Result? You could use something from the Throwable hierarchy there, but let’s forget about exceptions for a moment and introduce yet another coproduct type to model the errors:

public sealed interface Error {

    record UserNotFound(String userName) implements Error {
    }

    record OtherError(Throwable cause) implements Error {
    }
}

Finally, let’s use the above data model in our business logic that – for simplicity – would be a dummy UserService:

public class UserService {

    Result<Error, User> findUser() {
        return new Result.Failure<>(
            new Error.OtherError(new RuntimeException("boom!"))
        );
    }
}

Error handling with switch

With the data and business logic modeled as above, let’s now handle various possible outcomes of findUser() – using pattern matching, of course.

You can match on a successful result like this:

switch (userService.findUser()) {
    case Result.Success<?, User> success ->
        System.out.println(STR."User name is \{success.value().name()}");
}

Wondering what the STR thing is? Have a look at string templates (which are a preview feature though).

The first thing you’re going to observe about the code above is that it doesn’t compile – which is, fortunately, expected. Notice that you only covered the successful case in the pattern matching – but there’s also the failure scenario, and the compiler knows this. How does it know?

It’s thanks to the sealed keyword in the Result interface declaration, which, in this case, tells the compiler that all the implementations of Result are found in the file where Result is defined. If you defined anything that implements Result in a different file, the compiler would complain (you can work around this using the permits keyword – see more about the sealed type hierarchies here). Anyway, the bottomline is that the compiler knows (at, well, compile time) if all the possible cases were covered and it’s going to tell you if they are not (as you saw in the enum example in the beginning).

After adding an error case, the code is going to compile:

switch (userService.findUser()) {
    case Result.Success<?, User> success ->
        System.out.println(STR."User name is \{success.value().name()}");
    case Result.Failure<Error, ?> _ ->
        System.out.println("Something went wrong");    
}

Notice that the instance of the Failure object is ignored - this is achieved using the _, or the unnamed pattern, which is still a preview feature. If you don’t have preview features enabled, you need to put a valid identifier there and just not use it afterwards. Although a single underscore (_) is not a valid identifier in non-preview Java 21, a double underscore (__) is, so you can use the latter as a poor man’s unnamed pattern until the real one makes it out of preview.

Pattern matching on nested types

There are two areas in the above initial attempt that you could improve:

  • accessing the name field of the user in case of success,
  • more fine-grained error handling (remember that there are multiple variants of Error).

Since pattern matching also works on nested data structures, you can rewrite the successful branch as:

case Result.Success(User(var name, _)) -> 
    System.out.println(STR."User name is \{name}");

By matching on a User nested in a Result.Success you’re able to extract the name field and directly access it on the right-hand side of the arrow. Notice that you can also use the unnamed pattern (remember, it’s a preview feature in Java 21) to indicate that a field in a nested object is ignored.

Note that when matching on a concrete nested type (User), you didn’t need to provide the generic types for Result.Success anymore, since they were inferred by the compiler. Similarly, as in the previous Point example, the types of the fields of the User record were also inferred, thus you could use var name.

The when conditions also work as you would expect, e.g.

case Result.Success(User(var name, var age)) when age > 18 ->
    System.out.println(STR."\{name} is an adult");

In the failure scenario, so far you only matched on Error, which is on top of the error hierarchy. But nothing prevents you from using a more precise approach and matching on the specific errors – which are nothing more than nested records, after all:

case Result.Failure(Error.UserNotFound(var name)) ->
    System.out.println(STR."User \{name} not found");
case Result.Failure(Error.OtherError(var cause)) ->
    System.out.println(STR."Other error: \{cause.getMessage()}");

It’s worth noting that even with nested records, the exhaustiveness check still works as expected. If you commented out the first case above, the compiler would still be able to tell that you only matched on Result.Failure(Error.OtherError), while a failure with UserNotFound is not covered. Again, it’s extremely useful to have a compile-time check that all the possible cases were handled.

Summary

In this article, you could remind yourself how the switch statement has evolved. Since Java 14 it has been an expression that has a value, uses the “arrow labels” syntax with yield, does not fall through by default, and checks at compile time if all possible cases are covered.

You also learned how to use pattern matching to check if objects have an expected structure – with a built-in way to avoid NullPointerExceptions using the null label – and how to extract data from such a structure, with types automatically inferred by the compiler.

Finally, you got to know how to take the most out of the above machinery to perform fine-grained pattern matching on a sealed hierarchy of coproduct types (one of the Algebraic Data Types) implemented using nested records and the sealed keyword.

Do you like the new powers that the switch just gained? Let me know if you have any questions!

For the official docs, please refer to Java Language Updates, specifically the chapters on switch expressions and pattern matching.

Find out what Java Experts say about the newest Java release: Java21

Reviewed by: Darek Broda, Sebastian Rabiej, Rafał Maciak

Java

Blog Comments powered by Disqus.