Pattern Matching for switch (JEP 441)
Pattern matching for switch expressions and statements appeared as a preview feature in Java 17 (JEP 406), Java 18 (JEP 420), Java 19 (JEP 427), and Java 20 (JEP 433). This article covers the final specification which is part of Java 21 (JEP 441). The feature is mostly straightforward, with a few sharp edges. At the end of each section is a “sandbox” with somewhat contrived code to try out the syntax variations.
Type Checks with Switch
If you have many branches that check the same value, it can be clearer to refactor the code as a switch
:
if (code == 200) message = "Ok"; else if (code == 301) message = "Moved permanently"; else if (code == 404) message = "Not found"; ...
becomes
message = switch (code) { case 200 -> "Ok"; case 301 -> "Moved permanently"; case 404 -> "Not found; ... }
A sequence of type checks can be similarly repetitive:
if (out instanceof ByteArrayOutputStream bout) bout.writeBytes(str.getBytes()); else if (out instanceof DataOutputStream dout) dout.writeUTF(str); else if (out instanceof ObjectOutputStream oout) oout.writeObject(str); else out.write(str.getBytes());
Note the use of pattern matching for instanceof
. The code snippet declares variables (bout
, dout
, oout
) that contain out
, cast to the matching type.
The equivalent pattern matching switch
is:
switch (out) { case ByteArrayOutputStream bout -> bout.writeBytes(str.getBytes()); case DataOutputStream dout -> dout.writeUTF(str); case ObjectOutputStream oout -> oout.writeObject(str); default -> out.write(str.getBytes()); }
This example uses a statement/no fall through switch
. There are three more forms: expression/no fall through, statement/fall through, and expression/fall through. You can use type patterns with all of them.
You must use a variable after the type, even if you don’t need it. In the preceding example, you cannot replace the default
case with
case OutputStream -> out.write(str.getBytes()); // Error—no variable
Once JEP 443 is no longer a preview feature, we can use the _
keyword:
case OutputStream _ -> ...
For now, just use two underscores: case OutputStream __
.
In the sandbox, try using fall through for the second switch
. Try using a default
in the last case.
Guards
Sometimes, it is convenient to select a case of a switch
only when a certain condition is fulfilled. Such a condition is called a guard. The contextual keyword when
introduces a guard:
switch (out) { case ByteArrayOutputStream bout -> bout.writeBytes(str.getBytes()); case DataOutputStream dout -> dout.writeUTF(str); case ObjectOutputStream oout when str.length() > 0 -> oout.writeObject(str); default -> out.write(str.getBytes()); }
If the guard condition is not fulfilled, the case is not selected and the next case is tested.
In the Java 18 preview, guards were written as
case ObjectOutputStream oout && str.length() > 0
It seemed reasonable to use &&
to combine multiple tests, but there were subtle issues. The final design is similar to Scala match
expressions, where the if
keyword is used for guards.
This sandbox demonstrates a typical use of type patterns. An XML node can be an element, text node, comment, entity reference, processing instruction, or one of several other exotic things. This code only handles the first two, leaving the others as an exercise to the reader. Note the when
clause for skipping whitespace.
Null Handling
The classic switch
throws a NullPointerException
when the tested value is null
. That makes sense when switching on strings or enumerations. But with a type match, the issue is less clear. Ever since Java 1.0, instanceof
tests have been tolerant of null
. A test such as null instanceof String
simply returns false
.
You can now add a null
case to a switch
. In that case, the switch does not throw a NullPointerException
. The syntax is simply:
case null -> ...
In preview versions of this feature, you were allowed to combine null
with type tests:
switch (obj) { case String s, null -> ... // No longer allowed ... }
However, you can group the null
and default
cases:
case null, default -> ...
In this sandbox, fix the second switch so that it doesn’t throw a NullPointerException
if ex
is null
!
Dominance
Before type patterns, cases of a switch
were always disjoint. It was a compile-time error to include the same constant in multiple cases. However, type patterns can overlap:
switch (out) { case Appendable app -> ... case Closeable cl -> ... default -> ... }
There are classes that implement both the Appendable
and Closeable
interface, such as PrintStream
. Then the first matching case applies. Other matching cases are not executed (unless execution happens to fall through).
It is an error if a case is unreachable. Consider this example:
switch (ex) { case RuntimeException rex -> ... case NullPointerException nex -> ... // Error default -> ... }
The second case is unreachable since NullPointerException
is a subtype of RuntimeException
. We say that the first case dominates the second. This is a compile-time error.
An unguarded type pattern dominates a pattern with the same case and a guard:
switch (ex) { case Exception __ -> ... case Exception __ when ex.getCause() != null -> ... // Error default -> ... }
The second case can never execute, and a compile-time error occurs.
Since the compiler cannot determine when a guard is true, guarded cases are never used for dominance checking. Consider
case Integer n when n >= 600 -> ... case Integer n when n > 599 -> ... // Not a compile-time error
The second case can never execute. But the compiler does not know that.
An unguarded type pattern dominates a constant pattern:
case Integer n
dominates
case 404
but
case Integer n where n >= 400
does not.
It is a good idea to list constant patterns first, then type patterns:
case 200 -> ... case 404 -> ... case Integer n when n >= 600 -> ... case Integer n -> ...
The default
clause must come last. Even after case null
, which it does not cover!
By the way, the rules for constant cases have not changed. The constants and selector must be of type int
, char
, short
, byte
or their wrapper classes, String
, or an enumerated type.
You can’t have
case System.out -> ...
or
Number num = ... switch (num) { case 404 -> ... ... }
Here is a little exercise to practice the dominance rules. And yes, it is weird that you can use case Object n
or case Number n
when the only possible type match is Integer
.
Exhaustiveness
A switch expression must be exhaustive; that is, yield a value for every input. Of course, any switch with a default
case is exhaustive.
If the switch input is a sealed type, there is a known, finite number of subtypes. The switch is exhaustive if there are type patterns covering all subtypes.
It is possible that a sealed type evolves, acquiring additional subtypes. Then a switch over that type may no longer be exhaustive. That is problematic if the source file containing the switch is not recompiled. After all, it might be in a third party JAR. In order to detect such a scenario at runtime, the compiler adds a default
case that throws a MatchError
.
The compiler cannot interpret guards, so you need to have at least one unguarded pattern. For example,
case Integer n when n >= 600 -> ... case Integer n when n < 600 -> ...
is not exhaustive. Rewrite it as follows:
case Integer n when n >= 600 -> ... case Integer n -> ...
Even though there is no technical need for switch statements to be exhaustive, the compiler will check that all “modern” switch statements are. That applies to any switch statement that uses type or null
patterns. You may need to add
default: break;
or
default -> {}
In a switch statement with only constant cases, there is no exhaustiveness check. All your old switches will compile as usual.
This sandbox shows exhaustiveness checking with sealed classes. Try adding another subclass JSONComment
. (I know, JSON won’t ever have comments.)
Variable Scope
A type pattern introduces a variable. You can use that variable in a when
clause:
case String s when s.length() > 3 -> ...
You can also use the variable in the code to the right of the ->
or :
token.
case String s when s.length() > 3 -> s.substring(0, 3) + "..."
This is unsurprising. The only potentially confusing situation comes from fall through. Consider:
case Number n: ... // Must have break/yield here case String s when s.length() > 3: ... // No break/yield required default: ...
You cannot fall through a type pattern with a variable binding. That is, you cannot fall into the the second case. After all, falling through skips the test and goes directly into the code that follows. However, you can fall through from a type pattern into another case that isn’t a type pattern, or that is a type pattern with an unnamed variable. In the preceding example, it is ok to fall through the default.
Here is a complete example, as contrived as all fall through examples that I have ever seen.
Enum Constants
When the selector of a switch
has enum
type, then the case
labels are constants in the enumeration:
System.Logger.Level level = ...; switch (level) { case ERROR: ...; // i.e. System.Logger.Level.ERROR ... }
Note also that the labels are not “qualified”—that is, you omit the enum type name in a case
label.
Now consider a more complex case—a sealed hierarchy in which some subtypes are enumerations:
JSONPrimitive p = ...; switch (p) { case JSONNumber n -> ...; case JSONString s -> ...; case JSONNull.INSTANCE -> ...; // Can't be case INSTANCE case JSONBoolean.FALSE -> ...; case JSONBoolean.TRUE -> ...; }
In this situation, you can use case labels with enum
constants. However, they need to be qualified, since the compiler cannot deduce the enum
type from the selector.
With enum
constants (but not with numeric or String
constants), the rule that the selector type must match the constant type has been relaxed. The selector type can be any supertype.
How momentous are type patterns?
Type patterns provide a concise way of formulating repeated instanceof
tests. How often do you use instanceof
? The JDK source of over 5 million lines has just over 11,000 instanceof
tests, of which 10% were preceded by else
.
Clearly, there are times where type tests are necessary. Reviewing the JDK source revealed some common themes. Heterogeneous tree structures (XML, menus and submenus, parse trees). Ad-hoc formatting/parsing of strings, numbers, dates, arrays, and so on. Ad-hoc polymorphism with input sources and output targets. Special handling of certain user interface components. Analyzing exceptions. Handling the results from reflective calls.
My verdict: Nice to have in those cases, but not something that most people will use a lot.
As you saw, the devil is in the details. Put default
last. Stay away from fall through!
References
- JEP 394: Pattern Matching for instanceof, OpenJDK
- JEP 406: Pattern Matching for switch (Preview), OpenJDK
- JEP 420: Pattern Matching for switch (Second Preview), OpenJDK
- JEP 427: Pattern Matching for switch (Third Preview), OpenJDK
- JEP 433: Pattern Matching for switch (Fourth Preview), OpenJDK
- JEP 441: Pattern Matching for switch, OpenJDK