Guarded Patterns: Enhancing Pattern Matching with Conditional Logic in Java

Guarded patterns are an advanced feature in Java's pattern matching that allow you to add conditional logic to pattern matches using the when clause. This powerful combination enables more expressive and precise pattern matching by incorporating boolean conditions directly into case labels.

Evolution of Pattern Matching in Java

Java 14-16: instanceof Pattern Matching

// Traditional approach
if (obj instanceof String) {
String s = (String) obj;
if (s.length() > 5) {
System.out.println("Long string: " + s);
}
}
// Pattern matching with instanceof (Java 16+)
if (obj instanceof String s && s.length() > 5) {
System.out.println("Long string: " + s);
}

Java 17-20: Switch Pattern Matching

// Traditional switch
static String formatter(Object obj) {
String formatted = "unknown";
if (obj instanceof Integer i) {
formatted = String.format("int %d", i);
} else if (obj instanceof Long l) {
formatted = String.format("long %d", l);
} else if (obj instanceof Double d) {
formatted = String.format("double %f", d);
} else if (obj instanceof String s) {
formatted = String.format("String %s", s);
}
return formatted;
}
// Pattern matching switch (Java 21+)
static String formatterPatternSwitch(Object obj) {
return switch (obj) {
case Integer i -> String.format("int %d", i);
case Long l    -> String.format("long %d", l);
case Double d  -> String.format("double %f", d);
case String s  -> String.format("String %s", s);
default        -> "unknown";
};
}

Guarded Patterns with when

Guarded patterns extend pattern matching by allowing boolean expressions in case labels using the when clause.

Basic Syntax

case Pattern p when condition -> expression

Example 1: Basic Guarded Patterns

public class GuardedPatternsBasic {
// Simple sealed hierarchy
sealed interface Shape permits Circle, Rectangle, Triangle {
double area();
}
record Circle(double radius) implements Shape {
@Override public double area() { return Math.PI * radius * radius; }
}
record Rectangle(double width, double height) implements Shape {
@Override public double area() { return width * height; }
}
record Triangle(double base, double height) implements Shape {
@Override public double area() { return 0.5 * base * height; }
}
public static String analyzeShape(Shape shape) {
return switch (shape) {
case Circle c when c.radius() > 10 -> "Large circle with area: " + c.area();
case Circle c when c.radius() <= 10 -> "Small circle with area: " + c.area();
case Rectangle r when r.width() == r.height() -> 
"Square with area: " + r.area();
case Rectangle r when r.width() > r.height() -> 
"Wide rectangle with area: " + r.area();
case Rectangle r -> "Tall rectangle with area: " + r.area();
case Triangle t when t.area() > 100 -> "Large triangle";
case Triangle t -> "Small triangle";
};
}
public static void main(String[] args) {
System.out.println(analyzeShape(new Circle(5)));    // Small circle
System.out.println(analyzeShape(new Circle(15)));   // Large circle
System.out.println(analyzeShape(new Rectangle(10, 10))); // Square
System.out.println(analyzeShape(new Rectangle(15, 10))); // Wide rectangle
}
}

Example 2: Complex Domain Logic with Guards

public class GuardedPatternsAdvanced {
sealed interface PaymentMethod permits CreditCard, PayPal, BankTransfer {
String getProvider();
boolean isValid();
}
record CreditCard(String number, String holder, int expiryMonth, 
int expiryYear, String cvv) implements PaymentMethod {
@Override public String getProvider() { 
return number.startsWith("4") ? "Visa" : 
number.startsWith("5") ? "Mastercard" : "Unknown"; 
}
@Override public boolean isValid() {
return number != null && number.length() == 16 &&
cvv != null && cvv.length() == 3 &&
expiryYear >= 2024;
}
}
record PayPal(String email, boolean verified) implements PaymentMethod {
@Override public String getProvider() { return "PayPal"; }
@Override public boolean isValid() { return verified && email != null; }
}
record BankTransfer(String accountNumber, String routingNumber) implements PaymentMethod {
@Override public String getProvider() { return "Bank"; }
@Override public boolean isValid() { 
return accountNumber != null && routingNumber != null; 
}
}
public static String processPayment(PaymentMethod payment, double amount) {
return switch (payment) {
case CreditCard cc when !cc.isValid() -> 
"Invalid credit card details";
case CreditCard cc when cc.getProvider().equals("Visa") && amount > 1000 -> 
"Visa payment over $1000 requires additional verification";
case CreditCard cc when amount > 5000 -> 
"Large credit card payment: " + amount + " with " + cc.getProvider();
case CreditCard cc -> 
"Credit card payment processed: " + amount + " with " + cc.getProvider();
case PayPal pp when !pp.verified() -> 
"Please verify your PayPal account";
case PayPal pp when amount > 2000 -> 
"PayPal payment limit exceeded";
case PayPal pp -> 
"PayPal payment processed for: " + pp.email();
case BankTransfer bt when amount > 10000 -> 
"Large bank transfer requires manager approval";
case BankTransfer bt -> 
"Bank transfer initiated from account: " + bt.accountNumber();
};
}
public static void processPaymentBatch(List<PaymentMethod> payments) {
for (PaymentMethod payment : payments) {
String result = switch (payment) {
case CreditCard cc when cc.expiryYear() == 2024 -> 
"Credit card expiring soon: " + cc.holder();
case PayPal pp when pp.email().endsWith("@test.com") -> 
"Test PayPal account skipped: " + pp.email();
case BankTransfer bt when bt.accountNumber().startsWith("999") -> 
"Special internal account: " + bt.accountNumber();
default -> processPayment(payment, 100.0); // Default amount
};
System.out.println(result);
}
}
}

Example 3: Nested Patterns with Guards

public class NestedGuardedPatterns {
sealed interface ASTNode permits NumberNode, BinaryOp, UnaryOp, Variable {
double evaluate(Map<String, Double> context);
}
record NumberNode(double value) implements ASTNode {
@Override public double evaluate(Map<String, Double> context) { return value; }
}
record BinaryOp(String operator, ASTNode left, ASTNode right) implements ASTNode {
@Override 
public double evaluate(Map<String, Double> context) {
return switch (operator) {
case "+" -> left.evaluate(context) + right.evaluate(context);
case "-" -> left.evaluate(context) - right.evaluate(context);
case "*" -> left.evaluate(context) * right.evaluate(context);
case "/" -> left.evaluate(context) / right.evaluate(context);
default -> throw new IllegalArgumentException("Unknown operator: " + operator);
};
}
}
record UnaryOp(String operator, ASTNode operand) implements ASTNode {
@Override 
public double evaluate(Map<String, Double> context) {
return switch (operator) {
case "-" -> -operand.evaluate(context);
case "+" -> operand.evaluate(context);
default -> throw new IllegalArgumentException("Unknown operator: " + operator);
};
}
}
record Variable(String name) implements ASTNode {
@Override 
public double evaluate(Map<String, Double> context) {
if (!context.containsKey(name)) {
throw new IllegalArgumentException("Unknown variable: " + name);
}
return context.get(name);
}
}
public static String optimizeExpression(ASTNode node) {
return switch (node) {
// Constant folding
case BinaryOp op when isConstant(op.left()) && isConstant(op.right()) -> 
"Constant expression: " + op.evaluate(Map.of());
case BinaryOp(String op, NumberNode left, NumberNode right) 
when op.equals("+") && left.value() == 0 -> 
"Redundant addition with zero: " + optimizeExpression(right);
case BinaryOp(String op, NumberNode left, NumberNode right) 
when op.equals("*") && (left.value() == 1 || right.value() == 1) -> 
"Redundant multiplication with one";
case BinaryOp(String op, ASTNode left, ASTNode right) 
when op.equals("*") && (isZero(left) || isZero(right)) -> 
"Expression simplifies to zero";
case UnaryOp(String op, NumberNode n) when op.equals("-") -> 
"Negated constant: " + (-n.value());
// Identity operations
case BinaryOp(String op, Variable v1, Variable v2) 
when op.equals("-") && v1.name().equals(v2.name()) -> 
"Variable subtraction with itself: 0";
default -> "Cannot optimize: " + node;
};
}
private static boolean isConstant(ASTNode node) {
return node instanceof NumberNode;
}
private static boolean isZero(ASTNode node) {
return node instanceof NumberNode n && n.value() == 0;
}
public static void main(String[] args) {
ASTNode expr1 = new BinaryOp("+", new NumberNode(5), new NumberNode(3));
ASTNode expr2 = new BinaryOp("*", new NumberNode(1), new Variable("x"));
ASTNode expr3 = new BinaryOp("-", new Variable("x"), new Variable("x"));
System.out.println(optimizeExpression(expr1)); // Constant expression
System.out.println(optimizeExpression(expr2)); // Redundant multiplication
System.out.println(optimizeExpression(expr3)); // Variable subtraction
}
}

Example 4: Exception Handling with Guarded Patterns

public class ExceptionHandlingPatterns {
sealed interface Result<T> permits Success, Failure {
T getValue() throws Exception;
boolean isSuccess();
}
record Success<T>(T value) implements Result<T> {
@Override public T getValue() { return value; }
@Override public boolean isSuccess() { return true; }
}
record Failure<T>(Exception error) implements Result<T> {
@Override public T getValue() throws Exception { throw error; }
@Override public boolean isSuccess() { return false; }
}
public static <T> String handleResult(Result<T> result) {
return switch (result) {
case Success<T> s -> "Success: " + s.value();
case Failure<T> f when f.error() instanceof IllegalArgumentException -> 
"Invalid argument: " + f.error().getMessage();
case Failure<T> f when f.error() instanceof NullPointerException -> 
"Null value encountered";
case Failure<T> f when f.error().getMessage() != null 
&& f.error().getMessage().contains("timeout") -> 
"Operation timed out";
case Failure<T> f when isRecoverable(f.error()) -> 
"Recoverable error: " + f.error().getClass().getSimpleName();
case Failure<T> f -> 
"Critical failure: " + f.error().getClass().getSimpleName();
};
}
private static boolean isRecoverable(Exception error) {
return error instanceof IOException || 
error instanceof NumberFormatException;
}
public static void processResults(List<Result<?>> results) {
for (Result<?> result : results) {
String handling = switch (result) {
case Success<?> s when s.value() instanceof String str 
&& str.length() > 100 -> 
"Long string result: " + str.substring(0, 50) + "...";
case Success<?> s when s.value() instanceof Number num 
&& num.doubleValue() < 0 -> 
"Negative number result: " + num;
case Failure<?> f when f.error().getCause() != null -> 
"Chained exception: " + f.error().getCause().getMessage();
default -> handleResult(result);
};
System.out.println(handling);
}
}
}

Example 5: DOM Processing with Guarded Patterns

public class DOMPatternMatching {
sealed interface XMLNode permits Element, Text, Comment, CDATA {
String getContent();
}
record Element(String tagName, List<XMLNode> children, 
Map<String, String> attributes) implements XMLNode {
@Override 
public String getContent() {
return children.stream()
.map(XMLNode::getContent)
.collect(Collectors.joining());
}
public boolean hasAttribute(String name) {
return attributes.containsKey(name);
}
public String getAttribute(String name) {
return attributes.get(name);
}
}
record Text(String content) implements XMLNode {
@Override public String getContent() { return content; }
}
record Comment(String text) implements XMLNode {
@Override public String getContent() { return ""; }
}
record CDATA(String data) implements XMLNode {
@Override public String getContent() { return data; }
}
public static String processXMLNode(XMLNode node) {
return switch (node) {
case Element e when e.tagName().equals("script") || 
e.tagName().equals("style") -> 
"Skipping " + e.tagName() + " element";
case Element e when e.tagName().equals("a") && 
e.hasAttribute("href") -> 
"Link to: " + e.getAttribute("href");
case Element e when e.tagName().equals("img") && 
e.hasAttribute("src") -> 
"Image: " + e.getAttribute("src");
case Element e when e.children().isEmpty() -> 
"Empty element: " + e.tagName();
case Element e when e.children().size() > 10 -> 
"Large element with " + e.children().size() + " children";
case Text t when t.content().isBlank() -> 
"Whitespace text node";
case Text t when t.content().length() > 100 -> 
"Long text: " + t.content().substring(0, 50) + "...";
case Comment c when c.text().contains("TODO") -> 
"TODO comment: " + c.text();
case CDATA c when c.data().contains("<![CDATA[") -> 
"Nested CDATA section";
default -> "Processing: " + node.getClass().getSimpleName();
};
}
public static List<String> extractLinks(List<XMLNode> nodes) {
return nodes.stream()
.flatMap(node -> extractLinksFromNode(node).stream())
.collect(Collectors.toList());
}
private static List<String> extractLinksFromNode(XMLNode node) {
return switch (node) {
case Element e when e.tagName().equals("a") && 
e.hasAttribute("href") -> 
List.of(e.getAttribute("href"));
case Element e -> 
e.children().stream()
.flatMap(child -> extractLinksFromNode(child).stream())
.collect(Collectors.toList());
default -> List.of();
};
}
}

Best Practices and Considerations

1. Order Matters

public static String processOrderExample(Object obj) {
return switch (obj) {
case String s when s.length() > 10 -> "Long string";
case String s when s.isEmpty() -> "Empty string"; // This will never match!
case String s -> "Regular string";
default -> "Not a string";
};
}
// Correct ordering:
public static String processOrderCorrect(Object obj) {
return switch (obj) {
case String s when s.isEmpty() -> "Empty string";
case String s when s.length() > 10 -> "Long string";
case String s -> "Regular string";
default -> "Not a string";
};
}

2. Avoid Overly Complex Guards

// ❌ Too complex
case Person p when p.age() > 18 && p.address() != null && 
p.address().city().equals("London") && p.salary() > 50000 -> ...
// ✅ Better: Extract complex logic
case Person p when isHighEarningLondoner(p) -> ...
private static boolean isHighEarningLondoner(Person p) {
return p.age() > 18 && 
p.address() != null && 
p.address().city().equals("London") && 
p.salary() > 50000;
}

3. Use Exhaustiveness Checking

sealed interface PaymentResult permits Success, Failure, Pending { }
public static String handlePayment(PaymentResult result) {
return switch (result) {
case Success s -> "Payment successful";
case Failure f -> "Payment failed";
case Pending p -> "Payment pending"; // Required for exhaustiveness
};
}

Performance Considerations

  • Guarded patterns are evaluated in order, so put the most common cases first
  • Complex guard conditions can impact performance
  • The JVM can optimize pattern matching, but complex guards may hinder this

Conclusion

Guarded patterns with when clauses significantly enhance Java's pattern matching capabilities by:

  1. Adding conditional logic directly to pattern matches
  2. Improving readability by keeping related conditions together
  3. Reducing boilerplate compared to nested if-else statements
  4. Enabling more expressive domain-specific logic in pattern matches

Key benefits include:

  • Type safety with compile-time checking
  • Exhaustiveness checking for complete coverage
  • Better maintainability through clear, focused case logic
  • Integration with sealed classes for comprehensive domain modeling

As pattern matching continues to evolve in Java, guarded patterns provide a powerful tool for writing cleaner, more maintainable, and more expressive code, particularly in complex domain logic and data processing scenarios.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper