Pattern matching in Java is not a single feature — it is a progressive capability that was built over six releases. Each release added a layer: Java 14 started with basic instanceof type matching, Java 16 finalized it, Java 17 brought sealed classes to bound the type hierarchy, Java 21 finalized pattern matching for switch and record patterns. Understanding the progression matters because each layer is more useful with the ones underneath it, and because the code you write today using all of them together is qualitatively different from what was possible in Java 11.
The underlying problem being solved is type-based dispatch. When a method receives an Object or an interface and needs to behave differently depending on the runtime type, the pre-Java 14 approach was an instanceof check followed by an explicit cast. The check and the cast both contain the same type — the cast is redundant information that exists only because the compiler could not use the instanceof result to narrow the type. Pattern matching eliminates this redundancy step by step.
Pattern Matching for instanceof (Java 16): The Foundation
The first layer, finalized in Java 16, allows a type pattern in an instanceof expression. The pattern binds the checked value to a new variable of the checked type, eliminating the explicit cast. The bound variable is only in scope where the compiler can statically determine the check holds — in the true branch of an if statement, or in a short-circuit expression where the check is the left operand.
// Java 16+ — type patterns in instanceof; eliminates the cast-after-check pattern
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TypePatternExample {
private static final Logger log = LoggerFactory.getLogger(TypePatternExample.class);
// Before Java 16: every instanceof is followed by an identical cast
public double computeAreaBefore(Object shape) {
if (shape instanceof Circle) {
Circle c = (Circle) shape; // cast is redundant — type already checked
return Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.width() * r.height();
}
throw new IllegalArgumentException("Unknown: " + shape);
}
// Java 16+: check and bind in one expression; no cast needed
public double computeAreaAfter(Object shape) {
if (shape instanceof Circle c) { // 'c' bound to Circle
return Math.PI * c.radius() * c.radius(); // c available here
} else if (shape instanceof Rectangle r) {
return r.width() * r.height();
}
throw new IllegalArgumentException("Unknown: " + shape);
}
// Pattern variable scope is precise — only where the check is definitely true
public boolean isLarge(Object shape) {
// 'c' only in scope in the true branch of &&
return shape instanceof Circle c && c.radius() > 100;
// This would NOT compile: shape instanceof Circle c || c.radius() > 100
// Because || does not guarantee left side was true when right side runs
}
// Useful for equals() implementations
public boolean equals(Object obj) {
return obj instanceof TypePatternExample other
&& this.getClass() == other.getClass();
}
}Code language: Java (java)
Pattern Matching for switch (Java 21): Type Dispatch Without the Chain
Finalized in Java 21 (JEP 441), pattern matching for switch allows type patterns directly in switch cases. This replaces the instanceof chain entirely with a construct that is exhaustiveness-checked by the compiler when switching over sealed types, and that supports guarded patterns for conditional matching within a case.
The significant difference from instanceof chains is the compiler’s exhaustiveness enforcement. When the switch target type is sealed, the compiler verifies that every permitted subtype is handled. Adding a new permitted subtype with no corresponding case causes a compile error, not a runtime failure. This changes the risk profile of type-based dispatch substantially.
// Java 21+ — pattern matching for switch with guarded patterns
// Sealed interface so compiler can verify exhaustiveness
sealed interface ApiResponse permits ApiResponse.Ok, ApiResponse.Error, ApiResponse.Redirect {}
record ApiResponse.Ok(String body, int statusCode) implements ApiResponse {}
record ApiResponse.Error(String message, int statusCode, Throwable cause) implements ApiResponse {}
record ApiResponse.Redirect(String location) implements ApiResponse {}
public class ApiResponseHandler {
// switch as expression — returns a value; compiler verifies all types covered
public static String handleResponse(ApiResponse response) {
return switch (response) {
// Guarded pattern: case refines with a when clause
case ApiResponse.Ok ok when ok.statusCode() == 201 ->
"Resource created: " + ok.body();
case ApiResponse.Ok ok ->
"Success (" + ok.statusCode() + "): " + ok.body();
case ApiResponse.Error err when err.statusCode() >= 500 -> {
// Multi-statement block in switch arm
logServerError(err);
yield "Server error: " + err.message(); // yield returns from the block
}
case ApiResponse.Error err ->
"Client error (" + err.statusCode() + "): " + err.message();
case ApiResponse.Redirect redirect ->
"Redirect to: " + redirect.location();
// No default needed — ApiResponse is sealed, all types are covered above
};
}
private static void logServerError(ApiResponse.Error err) {
// log the error — omitted for brevity
}
}Code language: Java (java)
Record Patterns (Java 21): Destructuring in Type Checks
Record patterns (JEP 440), also finalized in Java 21, extend pattern matching to records. Instead of matching the type and then calling accessors, you extract the record’s components directly in the pattern. This is particularly useful for nested structures where successive accessor calls would otherwise clutter the code.
// Java 21+ — record patterns for inline destructuring of record components
record Point(double x, double y) {}
record Line(Point start, Point end) {}
record BoundingBox(Point topLeft, Point bottomRight) {}
public class RecordPatternExample {
// Destructure components directly in the pattern
public static String describeShape(Object shape) {
return switch (shape) {
// Extract x and y from Point directly — no accessor calls
case Point(double x, double y) when x == 0 && y == 0 ->
"Origin";
case Point(double x, double y) ->
String.format("Point(%.1f, %.1f)", x, y);
// Nested destructuring: extract Point components from inside Line
case Line(Point(double x1, double y1), Point(double x2, double y2)) ->
String.format("Line from (%.1f,%.1f) to (%.1f,%.1f)", x1, y1, x2, y2);
// Two levels of nesting
case BoundingBox(Point(double lx, double ly), Point(double rx, double ry)) ->
String.format("Box [%.1f,%.1f] to [%.1f,%.1f]", lx, ly, rx, ry);
default -> "Unknown shape: " + shape;
};
}
public static void main(String[] args) {
System.out.println(describeShape(new Point(0, 0)));
System.out.println(describeShape(new Line(new Point(1, 2), new Point(3, 4))));
}
}Code language: Java (java)
Nested record patterns are particularly useful when you have a domain model with layered value objects — coordinates inside locations inside regions, for example. The pattern extracts the values you need in one expression instead of a chain of accessor calls that the reader has to trace to understand what is being extracted.
Combining Patterns with Sealed Classes: The Full Picture
Pattern matching and sealed classes are designed to work together. Sealed classes define a closed, bounded type hierarchy. Pattern matching for switch provides exhaustiveness checking over that hierarchy. Together they give you a type-safe, compiler-verified way to express conditional logic based on type — what many languages call algebraic data types or tagged unions.
The practical pattern looks like this: define a sealed interface for a concept that has a fixed set of meaningful variants (payment results, validation outcomes, event types, command types), define each variant as a record implementing the sealed interface, and switch over the interface with type patterns wherever you need to act on the variant. The compiler tells you when a new variant is added without handling.
// Java 21+ — sealed + records + pattern matching for switch as algebraic data types
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
sealed interface ValidationResult
permits ValidationResult.Valid, ValidationResult.Invalid, ValidationResult.Skipped {
record Valid<T>(T value) implements ValidationResult {}
record Invalid(List<String> errors) implements ValidationResult {}
record Skipped(String reason) implements ValidationResult {}
}
public class OrderValidator {
private static final Logger log = LoggerFactory.getLogger(OrderValidator.class);
public void processValidation(ValidationResult result) {
switch (result) {
case ValidationResult.Valid<?> v -> {
log.info("Validation passed, processing: {}", v.value());
processOrder(v.value());
}
case ValidationResult.Invalid inv -> {
log.warn("Validation failed with {} errors", inv.errors().size());
inv.errors().forEach(e -> log.warn(" - {}", e));
rejectOrder(inv.errors());
}
case ValidationResult.Skipped s ->
log.info("Validation skipped: {}", s.reason());
// No default — sealed ensures all cases are handled
// Adding a new permitted type to ValidationResult causes compile error here
}
}
private void processOrder(Object value) { /* implementation */ }
private void rejectOrder(List<String> errors) { /* implementation */ }
}Code language: Java (java)
When Not to Use Pattern Matching
Pattern matching is the right tool when the type itself carries semantic meaning — when knowing the type tells you something meaningful about the data and what to do with it. It is the wrong tool when you are using types as an indirect way to convey state that should be modeled differently.
If you find yourself switching on the type of an object and every case does essentially the same thing with slightly different parameters, the operation probably belongs as a method on the type itself — classic polymorphism. Pattern matching and polymorphism solve different problems. Polymorphism is better when the behaviour varies and the data structure is stable. Pattern matching is better when the data structure varies (bounded, known set of types) and the behaviour may differ by call site.
Also avoid using pattern matching as a way to work around poor encapsulation — switching on internal state exposed through type checks is a design smell regardless of whether it uses instanceof or switch patterns.
Interview Questions
What happens if a new permitted subtype is added to a sealed interface and there is a pattern-matching switch over it without a default case?
The code fails to compile. The compiler enforces exhaustiveness for switch expressions and statements over sealed types — every permitted subtype must be handled by at least one case. Without a default clause, adding a new permitted type to the sealed hierarchy immediately surfaces all switch statements that need updating, at compile time rather than runtime. This is the primary advantage of sealed types with pattern matching over open class hierarchies: missing cases become compile errors, not runtime exceptions.
How does a guarded pattern differ from a nested if statement inside a switch arm?
A guarded pattern — case Type t when condition — is evaluated as part of the switch selector logic. If the condition is false, the switch continues to the next case rather than falling out of the switch. A nested if inside a switch arm is evaluated after the arm is selected. If the if condition is false, you must either yield a value, throw, or the switch arm falls through (with the arrow syntax, there is no fall-through, so you would need to yield or throw). Guarded patterns lead to cleaner code when multiple cases share a type but differ by condition, because the conditions appear at the case level where the reader expects to see dispatch logic.
What issues arise when switching on a sealed interface that has a non-sealed permitted class?
A non-sealed class in the permitted hierarchy means that class can be extended by anyone. The compiler cannot enumerate the subtypes of a non-sealed class, so a switch that covers the non-sealed class must either handle it with a type pattern that matches the class itself, or include a default case. Without a default, the switch is not exhaustive from the compiler’s perspective because unknown subtypes of the non-sealed class could appear at runtime. The exhaustiveness guarantee only applies to the direct permitted subtypes of the sealed interface — not to the potentially open subhierarchy under any non-sealed member.
Summary
Pattern matching in Java solves type-based dispatch cleanly: instanceof type patterns eliminate redundant casts, switch type patterns eliminate instanceof chains, guarded patterns handle conditional refinement within a case, and record patterns eliminate accessor chains in type checks. The feature set reaches its full value when combined with sealed classes, which let the compiler verify that every possible type is handled. The combination gives you compiler-enforced exhaustive dispatch over a bounded type hierarchy — the kind of guarantee that previously required either open inheritance with abstract methods or runtime checks that could miss cases.




