Pattern Matching in Java
Master Java pattern matching with instanceof, switch expressions, record patterns, and guarded patterns for cleaner, more expressive conditional logic.
What you'll learn
- ✓How instanceof pattern matching eliminates casts
- ✓How switch pattern matching handles type hierarchies
- ✓How record patterns destructure data in one step
- ✓How guarded patterns add conditions to match arms
- ✓Practical patterns for cleaner Java code
Prerequisites
- •Java OOP basics
- •Switch expressions (Java 14+)
- •Records and sealed classes basics
Pattern matching lets you test a value against a pattern and extract data from it in a single step. Java has been adding pattern matching incrementally: instanceof patterns in Java 16, switch patterns in Java 21, and record patterns in Java 21. Together, they eliminate the boilerplate of type-check-then-cast sequences and make conditional logic significantly cleaner.
instanceof pattern matching (Java 16)
Before pattern matching, type checking required a separate cast:
// old way: check, then cast
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
// with pattern matching: check and bind in one step
if (obj instanceof String s) {
System.out.println(s.toUpperCase());
}
The pattern variable s is only in scope where the pattern is guaranteed to have matched:
// s is in scope in the if block
if (obj instanceof String s) {
System.out.println(s.length());
}
// s is NOT in scope here
// also works with && (s guaranteed to be bound)
if (obj instanceof String s && s.length() > 5) {
System.out.println("Long string: " + s);
}
// does NOT work with || (s might not be bound)
// if (obj instanceof String s || s.isEmpty()) { } // compile error
Negation pattern
// common pattern: early return on type mismatch
public boolean equals(Object obj) {
if (!(obj instanceof Point other)) {
return false;
}
// 'other' is in scope here because we returned above
return this.x == other.x && this.y == other.y;
}
Practical: simplifying equals
public record Rectangle(double width, double height) {
@Override
public boolean equals(Object obj) {
return obj instanceof Rectangle r
&& Double.compare(width, r.width) == 0
&& Double.compare(height, r.height) == 0;
}
}
Switch pattern matching (Java 21)
Switch can now match on types, not just constants:
static String format(Object obj) {
return switch (obj) {
case Integer i -> "int: %d".formatted(i);
case Long l -> "long: %d".formatted(l);
case Double d -> "double: %.2f".formatted(d);
case String s -> "string: \"%s\"".formatted(s);
case null -> "null";
default -> "unknown: " + obj.getClass().getName();
};
}
System.out.println(format(42)); // int: 42
System.out.println(format(3.14)); // double: 3.14
System.out.println(format("hello")); // string: "hello"
System.out.println(format(null)); // null
Null handling
Before Java 21, passing null to a switch always threw NullPointerException. Now you can handle it explicitly:
static int length(String s) {
return switch (s) {
case null -> 0;
case "" -> 0;
default -> s.length();
};
}
Dominance ordering
More specific patterns must come before more general ones. The compiler enforces this:
// CORRECT: specific before general
switch (obj) {
case String s -> handleString(s);
case Object o -> handleOther(o); // catches everything else
}
// COMPILE ERROR: Object dominates String
switch (obj) {
case Object o -> handleOther(o); // matches everything
case String s -> handleString(s); // unreachable
}
Guarded patterns (Java 21)
Add conditions to pattern match arms using when:
static String categorize(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "negative integer";
case Integer i when i == 0 -> "zero";
case Integer i when i > 0 -> "positive integer";
case String s when s.isEmpty() -> "empty string";
case String s -> "string: " + s;
case null -> "null";
default -> "other";
};
}
System.out.println(categorize(-5)); // negative integer
System.out.println(categorize(0)); // zero
System.out.println(categorize("")); // empty string
System.out.println(categorize("hi")); // string: hi
Practical: HTTP response handling
record HttpResponse(int statusCode, String body) {}
String handleResponse(HttpResponse response) {
return switch (response) {
case HttpResponse r when r.statusCode() == 200
-> "Success: " + r.body();
case HttpResponse r when r.statusCode() >= 400 && r.statusCode() < 500
-> "Client error %d".formatted(r.statusCode());
case HttpResponse r when r.statusCode() >= 500
-> "Server error %d".formatted(r.statusCode());
case HttpResponse r when r.statusCode() >= 300
-> "Redirect %d".formatted(r.statusCode());
case HttpResponse r
-> "Other: " + r.statusCode();
};
}
Record patterns (Java 21)
Record patterns destructure records directly in pattern matching. Instead of binding the record and then calling accessors, you bind the components directly:
record Point(double x, double y) {}
// without record patterns
if (obj instanceof Point p) {
double x = p.x();
double y = p.y();
System.out.println("(%f, %f)".formatted(x, y));
}
// with record patterns: destructure in the pattern
if (obj instanceof Point(double x, double y)) {
System.out.println("(%f, %f)".formatted(x, y));
}
In switch expressions
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
double area(Shape shape) {
return switch (shape) {
case Circle(var r) -> Math.PI * r * r;
case Rectangle(var w, var h) -> w * h;
case Triangle(var b, var h) -> 0.5 * b * h;
};
}
Nested record patterns
Record patterns can be nested to destructure deeply:
record Point(double x, double y) {}
record Line(Point start, Point end) {}
record ColoredLine(Line line, String color) {}
String describe(Object obj) {
return switch (obj) {
case ColoredLine(Line(Point(var x1, var y1), Point(var x2, var y2)), var color)
-> "%s line from (%.1f,%.1f) to (%.1f,%.1f)".formatted(color, x1, y1, x2, y2);
case Line(Point(var x1, var y1), Point(var x2, var y2))
-> "Line from (%.1f,%.1f) to (%.1f,%.1f)".formatted(x1, y1, x2, y2);
case Point(var x, var y)
-> "Point at (%.1f,%.1f)".formatted(x, y);
default -> obj.toString();
};
}
var line = new ColoredLine(
new Line(new Point(0, 0), new Point(3, 4)),
"red"
);
System.out.println(describe(line));
// red line from (0.0,0.0) to (3.0,4.0)
Record patterns with guards
record Temperature(double value, String unit) {}
String classify(Temperature temp) {
return switch (temp) {
case Temperature(var v, "celsius") when v < 0 -> "Freezing";
case Temperature(var v, "celsius") when v < 20 -> "Cold";
case Temperature(var v, "celsius") when v < 30 -> "Comfortable";
case Temperature(var v, "celsius") -> "Hot";
case Temperature(var v, "fahrenheit") when v < 32 -> "Freezing";
case Temperature(var v, "fahrenheit") when v < 68 -> "Cold";
case Temperature(var v, "fahrenheit") when v < 86 -> "Comfortable";
case Temperature(var v, "fahrenheit") -> "Hot";
case Temperature(_, var unit) -> "Unknown unit: " + unit;
};
}
Exhaustiveness with sealed types
When you switch on a sealed type, the compiler ensures you handle all cases:
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr operand) implements Expr {}
// compiler verifies this is exhaustive
double evaluate(Expr expr) {
return switch (expr) {
case Num(var v) -> v;
case Add(var l, var r) -> evaluate(l) + evaluate(r);
case Mul(var l, var r) -> evaluate(l) * evaluate(r);
case Neg(var e) -> -evaluate(e);
// no default needed: all cases covered
};
}
String prettyPrint(Expr expr) {
return switch (expr) {
case Num(var v) -> String.valueOf(v);
case Add(var l, var r) -> "(%s + %s)".formatted(prettyPrint(l), prettyPrint(r));
case Mul(var l, var r) -> "(%s * %s)".formatted(prettyPrint(l), prettyPrint(r));
case Neg(var e) -> "(-%s)".formatted(prettyPrint(e));
};
}
Practical example: JSON-like data
sealed interface JsonValue permits JsonString, JsonNumber, JsonBool, JsonNull, JsonArray, JsonObject {}
record JsonString(String value) implements JsonValue {}
record JsonNumber(double value) implements JsonValue {}
record JsonBool(boolean value) implements JsonValue {}
record JsonNull() implements JsonValue {}
record JsonArray(List<JsonValue> elements) implements JsonValue {}
record JsonObject(Map<String, JsonValue> fields) implements JsonValue {}
String stringify(JsonValue value) {
return switch (value) {
case JsonString(var s) -> "\"%s\"".formatted(s);
case JsonNumber(var n) -> String.valueOf(n);
case JsonBool(var b) -> String.valueOf(b);
case JsonNull() -> "null";
case JsonArray(var elems) -> elems.stream()
.map(e -> stringify(e))
.collect(Collectors.joining(", ", "[", "]"));
case JsonObject(var fields) -> fields.entrySet().stream()
.map(e -> "\"%s\": %s".formatted(e.getKey(), stringify(e.getValue())))
.collect(Collectors.joining(", ", "{", "}"));
};
}
Pattern matching evolution timeline
| Version | Feature |
|---|---|
| Java 16 | instanceof pattern matching |
| Java 17 | Sealed classes (final) |
| Java 21 | Switch pattern matching (final) |
| Java 21 | Record patterns (final) |
| Java 21 | Guarded patterns with when |
Summary
Pattern matching in Java is not a single feature but a family of features that work together. instanceof patterns eliminate casts. Switch patterns eliminate type-checking chains. Record patterns eliminate accessor calls. Guarded patterns eliminate nested conditions. Sealed types ensure exhaustiveness. Together, they let you write conditional logic that is shorter, safer, and more readable than the traditional Java approach of instanceof checks, casts, and accessor chains.
Related articles
- Java Java CompletableFuture Patterns for Async Code
Master CompletableFuture composition patterns including chaining, combining, error handling, timeouts, and async pipeline design in Java.
- Java Java Optional: Avoiding NullPointerException
Learn how to use Java Optional to write null-safe code. Covers creation, chaining with map and flatMap, best practices, and common anti-patterns to avoid.
- Java Java Streams API: A Complete Guide
Master Java Streams for functional-style data processing. Covers stream creation, map, filter, reduce, collectors, parallel streams, and performance considerations.
- Java Java Collections Framework: List, Set, Map
Pick the right Java collection by performance and semantics. ArrayList vs LinkedList, HashMap vs TreeMap, immutability, and iteration without surprises.