Pattern matching streamlines your code by allowing you to check whether a value matches a specific structure or type—eliminating the need for repetitive if statements and manual type casting. It enables cleaner, more readable code by letting Java handle the complex checks for you.
Pattern matching in Java helps developers write more concise and efficient code. For instance, instead of manually checking whether an object is a String and then casting it, pattern matching allows you to perform both operations in a single, streamlined step.
Key Benefits of Pattern Matching:
- Reduced Boilerplate: No need for repetitive instanceof checks followed by explicit casting. The code becomes shorter and more elegant.
- Improved Readability: Type checks and variable declarations happen together, making the logic easier to understand at a glance.
- Fewer Errors: Since Java handles the casting automatically, it reduces the risk of runtime exceptions like ClassCastException.
Naive Approach to Type Checking in Java
if (object instanceof String) {
String s = (String) object;
System.out.println(s.length());
}
Better approach
if (object instanceof String s) {
System.out.println(s.length());
}
The benefits are clear:
- No redundant casting: The variable s is immediately available as a String, with no need for manual type conversion.
- Cleaner logic: Type checking and variable assignment are combined in a single, concise line.
- Fewer bugs: Eliminates the risk of ClassCastException by letting Java safely handle the casting behind the scenes.
Modern Switch Pattern Matching (Java 21 and Beyond)
In earlier versions of Java, switch statements were limited to simple types like int, enum, or String. But starting with Java 21, pattern matching extends the power of switch by allowing you to match against an object's type directly. This results in cleaner, more expressive, and type-safe code.
Here’s how type checking used to look without pattern matching:
if (obj instanceof String) {
String s = (String) obj;
System.out.println("It's a String: " + s);
} else if (obj instanceof Integer) {
Integer i = (Integer) obj;
System.out.println("It's an Integer: " + i);
} else {
System.out.println("Unknown type");
}
With switch and pattern matching, you can match types and extract variables in one step:
switch (obj) {
case String s -> System.out.println("It's a String: " + s);
case Integer i -> System.out.println("It's an Integer: " + i);
default -> System.out.println("Unknown type");
}
This is a significant improvement over the old way, where switch could only match constants:
switch (number) {
case 1 -> System.out.println("One"); // Old: only constants
case 2 -> System.out.println("Two");
}
Going Beyond the Basics: Advanced Pattern Matching in Java
Java 16 introduced basic pattern matching using instanceof, but Java 21 brought more sophisticated features. In this section, you’ll learn how to work with record patterns, nested record patterns, and guarded patterns in your Java code.
Record Patterns
Record patterns enable you to access the components of a record in a single, streamlined operation. This adds powerful deconstruction capabilities to Java, akin to those in functional programming languages.
Using record patterns, you can deconstruct records—Java’s immutable data classes—directly within pattern-matching contexts such as instanceof checks and switch statements. Instead of manually extracting each field, you can retrieve all components at once.
For example, consider the following usage of record patterns with instanceof:
record Point(int x, int y) {}
// Without record patterns
if (p instanceof Point) {
Point point = (Point) p;
int x = point.x();
int y = point.y();
System.out.println(x + ", " + y);
}
// With record patterns
if (p instanceof Point(int x, int y)) {
System.out.println(x + ", " + y); // x and y are directly accessible
}
Record patterns become even more powerful when combined with switch statements:
Object obj = new Point(10, 20);
switch (obj) {
case Point(int x, int y) when x > 0 && y > 0 ->
System.out.println("Point in first quadrant: " + x + ", " + y);
case Point(int x, int y) ->
System.out.println("Point elsewhere: " + x + ", " + y);
default ->
System.out.println("Not a point");
}
Mastering Nested Record Patterns in Java
Nested record patterns enable you to inspect records that include other records in a single, unified operation. This greatly simplifies handling complex, layered data.
With nested patterns, you can simultaneously match and extract values from multiple levels within a data structure, allowing direct access to inner components without having to traverse each level individually.
// Define two simple records
record Address(String city, String country) {}
record Person(String name, Address address) {}
// Create a person with an address
Person person = new Person("Rafael", new Address("Sao Paulo", "Brazil"));
For comparison, here’s how matching and extracting values looks without nested patterns:
// Multiple steps required
if (person instanceof Person) {
String name = person.name();
Address address = person.address();
String city = address.city();
String country = address.country();
System.out.println(name + " lives in " + city + ", " + country);
}
And this is how you’d write it using nested patterns:
// One clean pattern
if (person instanceof Person(String name, Address(String city, String country))) {
// All variables are immediately available
System.out.println(name + " lives in " + city + ", " + country);
}
Similar to standard record patterns, nested record patterns can also be applied within switch statements. In the first case of the example below, the condition matches only when the person’s country is "Ireland"; if not, the switch moves on to evaluate the other case.
switch (person) {
case Person(String name, Address(String city, "Ireland")) ->
System.out.println(name + " lives in " + city + ", Ireland");
case Person(String name, Address(String city, String country)) ->
System.out.println(name + " lives in " + city + ", " + country);
}
The advantages are evident once more:
- Reduced boilerplate: Nested record patterns enable extracting multiple components in one concise step.
- Enhanced readability: The pattern explicitly reveals the data being extracted.
- Type safety: The compiler guarantees that the extracted variables have the correct types.
- Seamless nested deconstruction: Complex data structures are handled effortlessly within a single pattern.
By using nested patterns, your code becomes clearer and easier to maintain—especially when dealing with intricate data like configuration settings, ___domain models, or API responses.
Using Guarded Patterns (When Clauses) for Precise Matching
Guarded patterns extend pattern matching by allowing you to include additional conditions using the when keyword. This lets you match objects not only by their type or structure but also based on the specific values they hold.
As demonstrated below, a guarded pattern pairs a standard pattern with a Boolean expression to create more precise matches:
switch (obj) {
case String s when s.length() > 5 ->
System.out.println("Long string: " + s);
case String s ->
System.out.println("Short string: " + s);
default ->
System.out.println("Not a string");
}
Here’s how this code operates:
- Java first verifies whether the object fits the specified pattern (for example, whether it’s a String).
- If it matches, Java assigns the matched value to the variable (s).
- Then, Java evaluates the Boolean condition following the when clause.
- If the condition is satisfied, that case is executed.
Let’s explore a couple of practical examples. In the first example, a guarded pattern is used to check the content of a String:
switch (input) {
case String s when s.startsWith("http://") ->
System.out.println("HTTP URL");
case String s when s.startsWith("https://") ->
System.out.println("Secure HTTPS URL");
case String s ->
System.out.println("Not a URL");
}
And here, we’re using the pattern with numbers:
switch (num) {
case Integer i when i < 0 -> System.out.println("Negative");
case Integer i when i == 0 -> System.out.println("Zero");
case Integer i when i > 0 -> System.out.println("Positive");
default -> System.out.println("Not an integer");
}
The advantages of guarded patterns include:
- More precise matching: Ability to target specific value-based conditions.
- Less code: Merges type checks and value validations into a single step.
- Improved readability: Expresses complex conditions clearly and succinctly.
Leveraging Pattern Matching with Sealed Classes
Using sealed classes together with pattern matching provides strong compile-time safety for your code. The examples in this section will demonstrate why this combination is truly transformative.
What are sealed classes?
Introduced in Java 17, sealed classes are classes that explicitly define which other classes can extend or implement them. Think of a sealed class as creating a “closed club” of related types:
sealed interface JavaMascot permits Duke, Juggy { }
record Duke(String color, int yearIntroduced) implements JavaMascot { }
record Juggy(String color, boolean isFlying) implements JavaMascot { }
At first glance, this code doesn’t seem to help much, but the real benefit starts when we try something like the following:
record Moby(String color, double tentacleLength) implements JavaMascot { } // Compilation error here
Since the sealed interface JavaMascot allows only Duke and Juggy to implement it, the code above will fail to compile. This restriction prevents unauthorized classes from implementing the JavaMascot interface, thereby reducing the risk of bugs. In general, tighter constraints in your code lead to fewer opportunities for errors.
Leveraging Sealed Classes within Switch Constructs
By combining sealed classes with switch in pattern matching, the compiler becomes fully aware of every possible subtype. This brings two key advantages: (1) the compiler can ensure that all subtypes are properly handled, and (2) because all cases are known upfront, there’s no need for a default case.
Let’s see how sealed classes and switch collaborate in the example below.
String describeMascot(JavaMascot mascot) {
return switch (mascot) {
case Duke(String color, int yearIntroduced) ->
"Duke (" + color + ") from " + yearIntroduced;
case Juggy(String color, boolean isFlying) ->
"Juggy (" + color + ")" + (isFlying ? " flying high" : "");
// No default needed! The compiler knows these are all possibilities
};
}
Java 23+ Enhancements: Primitive Type Pattern Matching
Recent Java versions have introduced an exciting preview feature that allows pattern matching to work with primitive types, enabling compatibility checks directly in your code. We’ll explore this feature through several examples. Keep in mind, however, that since primitive type pattern matching is still in preview, it may be modified or removed in future Java releases. To run the examples below, make sure to enable preview features in your environment.
java --enable-preview YourClassNameHere
What You Need to Know About Primitive Type Pattern Matching
This feature allows us to check whether a value of one primitive type can be safely represented as another primitive type, and if so, bind that value to a new variable. The following example demonstrates this with integer compatibility in action:
int count = 98;
if (count instanceof byte smallCount) {
// This executes only if count fits in a byte's range
System.out.println("Small enough: " + smallCount);
} else {
System.out.println("Number too large for byte storage");
}
Here, we’re checking if 98 can be stored in a byte. Since it’s between -128 and 127, the condition succeeds.
Consider another example, this one evaluating decimal precision:
double measurement = 17.5;
if (measurement instanceof float simpleMeasurement) {
System.out.println("No precision loss: " + simpleMeasurement);
} else {
System.out.println("Requires double precision");
}
This verifies if the double value can be represented as a float without precision loss.
Here’s an example using primitive type pattern matching with text characters:
int codePoint = 90;
if (codePoint instanceof char symbol) {
System.out.println("This represents: '" + symbol + "'");
} else {
System.out.println("Not a valid character code");
}
The output from this code would be: This represents: ‘Z’, because 90 is the ASCII/Unicode value for Z.
Finally, here’s a demonstration showing multiple type compatibility checks:
void examineNumber(long input) {
System.out.println("Examining: " + input);
if (input instanceof byte b)
System.out.println("Fits in a byte variable: " + b);
if (input instanceof short s)
System.out.println("Fits in a short variable: " + s);
if (input >= 0 && input <= 65535 && input instanceof char c)
System.out.println("Represents character: '" + c + "'");
if (input instanceof int i)
System.out.println("Fits in an int variable: " + i);
}
When called with examineNumber(77), this code would output all four messages, including that 77 represents the character ‘M’.
When to Use Primitive Type Pattern Matching
Primitive type pattern matching is especially useful in scenarios such as:
- Validating user input to ensure it falls within acceptable ranges.
- Safely converting between numeric types while minimizing data loss.
- Handling character encoding during text processing.
- Making numeric type conversions clearer and more reliable.
Top comments (0)