Java 21: switch the power on
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
andy
fields so that they are accessible without referring to aPoint
instance (notice how the compiler is able to infer the types forx
andy
, which lets you usevar
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 NullPointerException
s 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
Might interests you:
Threads, ThreadPools and Executors - Multi Thread Processing In Java
Benchmarking Java Streams