5.1 Instanceof and Switch Patterns
Pattern matching in Java eliminates the traditional test-cast-use pattern by allowing you to test an object's type and bind it to a variable in a single expression. This feature dramatically reduces boilerplate code and improves readability, especially when working with polymorphic data structures and sealed type hierarchies.
Modern pattern matching supports three key capabilities: pattern variables in instanceof expressions, type patterns in switch statements with exhaustiveness checking, and guard clauses for additional filtering. Combined with records and sealed types, these features enable concise, type-safe data processing.
Pattern Variables in instanceof
The traditional approach to type checking required three separate steps: test the type with instanceof, cast the object, and then use it. Pattern matching consolidates this into a single expression where the pattern variable is automatically available in the scope.
Traditional approach:
Object obj = fetchData();
if (obj instanceof String) {
String str = (String) obj;
System.out.println(str.toUpperCase());
}
With pattern matching:
Object obj = fetchData();
if (obj instanceof String str && !str.isBlank()) {
System.out.println(str.toUpperCase());
}
The pattern variable str is in scope only when the instanceof test succeeds, and can be used immediately in compound conditions. This approach is both safer and more concise, as the compiler ensures type correctness.
Type Patterns in Switch Expressions
Switch expressions can now match on types, not just constants. This is particularly powerful when combined with sealed types, as the compiler can verify that all possible cases are handled (exhaustiveness checking).
Basic type pattern switch:
String describe(Object obj) {
return switch (obj) {
case Integer i -> "Integer: " + i;
case String s -> "String of length " + s.length();
case Double d -> "Double: " + d;
default -> "Unknown type";
};
}
Guard Clauses with when
Guards allow you to add additional conditions to pattern cases using the when keyword. This eliminates nested if statements and makes the intent of each case clearer.
sealed interface Event permits UserCreated, UserDisabled {}
record UserCreated(UUID id, String email) implements Event {}
record UserDisabled(UUID id, Instant at) implements Event {}
String routeEvent(Event event) {
return switch (event) {
case UserCreated(var id, var email)
when email.endsWith("@example.com") -> "internal-queue";
case UserCreated(var id, var email) -> "external-queue";
case UserDisabled(var id, var timestamp)
when timestamp.isAfter(Instant.now().minusSeconds(60)) -> "recent-audit-log";
case UserDisabled(var id, var timestamp) -> "audit-log";
};
}
The guard clause is evaluated only after the pattern matches, and the pattern variables are available in the guard expression.
Record Patterns and Destructuring
Record patterns enable deep destructuring of nested record structures directly in the pattern. This is especially useful for processing hierarchical data without intermediate variable assignments.
record Address(String city, String country) {}
record Person(String name, Address address) {}
String locationSummary(Person person) {
return switch (person) {
case Person(var name, Address(var city, "USA")) ->
name + " is in " + city + ", United States";
case Person(var name, Address(var city, var country)) ->
name + " is in " + city + ", " + country;
};
}
Nested destructuring example:
record Price(Money amount, String currency) {}
record Money(BigDecimal value) {}
String formatPrice(Price price) {
return switch (price) {
case Price(Money(var value), var currency)
when value.compareTo(BigDecimal.ZERO) > 0 ->
value.toPlainString() + " " + currency;
case Price(Money(var value), var currency) ->
"Free (normally " + currency + ")";
};
}
Exhaustiveness Checking with Sealed Types
When switching over a sealed type, the compiler ensures all permitted subtypes are covered. This compile-time guarantee means you can omit the default case, making the code more maintainable—adding a new subtype will trigger a compilation error until all switches are updated.
sealed interface Result permits Success, Failure {}
record Success(String data) implements Result {}
record Failure(String error, int code) implements Result {}
void processResult(Result result) {
switch (result) {
case Success(var data) -> System.out.println("Success: " + data);
case Failure(var error, var code) ->
System.err.println("Error " + code + ": " + error);
// No default needed - compiler verifies exhaustiveness
}
}
Best Practices
Prefer pattern switch over instanceof chains:
- Pattern switches are more declarative and easier to maintain
- Exhaustiveness checking catches missing cases at compile time
- Better performance due to optimized dispatch
Use guards for complex conditions:
- Guards keep pattern matching logic at the switch level
- Avoid nesting if statements inside case blocks
- Make special cases explicit and self-documenting
Leverage sealed types for exhaustiveness:
- Design sealed hierarchies for sum types and state machines
- Rely on compiler verification rather than runtime defaults
- Make illegal states unrepresentable
Destructure records directly:
- Avoid intermediate variables when accessing nested fields
- Use nested patterns to express data shape requirements
- Combine with guards for conditional extraction