If you have written Java for any length of time, you have written this pattern hundreds of times: check if an object is a certain type with instanceof, then immediately cast it to that type so you can use it. It works, but it is repetitive, error-prone, and makes your code harder to read.
Here is the classic pattern every Java developer knows by heart:
// The old way -- check, then cast, then use
if (obj instanceof String) {
String s = (String) obj; // redundant -- we JUST checked it's a String
System.out.println(s.toUpperCase());
}
// More realistic example from real codebases
public void processShape(Object shape) {
if (shape instanceof Circle) {
Circle circle = (Circle) shape;
double area = Math.PI * circle.getRadius() * circle.getRadius();
System.out.println("Circle area: " + area);
} else if (shape instanceof Rectangle) {
Rectangle rect = (Rectangle) shape;
double area = rect.getWidth() * rect.getHeight();
System.out.println("Rectangle area: " + area);
} else if (shape instanceof Triangle) {
Triangle tri = (Triangle) shape;
double area = 0.5 * tri.getBase() * tri.getHeight();
System.out.println("Triangle area: " + area);
}
}
Look at the processShape method. Every single branch does the same dance: instanceof check, cast to the type we already know it is, then use the casted variable. The cast on line 2 is completely redundant — the compiler already confirmed the type on line 1. Multiply this across a large codebase, and you get thousands of lines of boilerplate that add nothing but visual noise and opportunities for bugs.
What could go wrong with the old pattern?
Copy-paste bugs — You check for Circle but accidentally cast to Rectangle. The compiler cannot catch this because both casts are valid Object-to-subtype operations. You get a ClassCastException at runtime.
Variable name pollution — You need separate variable names (circle, rect, tri) even though only one is ever used.
Three statements for one concept — “If this is a String, use it as a String” is one idea expressed in three lines of code.
Refactoring hazard — When refactoring, you might change the instanceof check but forget to update the cast, or vice versa.
Java 16 introduced Pattern Matching for instanceof (JEP 394), and it became a permanent feature in Java 17 as part of the LTS release. It eliminates this redundancy by letting you declare a pattern variable directly in the instanceof expression. The compiler handles the cast for you, and the variable is only in scope where it is safe to use.
Think of it like a customs officer at an airport. The old way: the officer checks your passport (instanceof), takes your passport away (cast), writes your name on a sticky note (new variable), and hands the sticky note back to you. The new way: the officer checks your passport and just lets you through — you are you, no sticky note needed.
Aspect
Old Pattern (Pre-Java 16)
Pattern Matching (Java 16+)
Lines of code
3 (check + cast + use)
1 (check + bind + use)
Redundant cast
Yes — you manually cast after already checking
No — compiler auto-casts
Type safety
Cast can mismatch the check (runtime error)
Impossible to mismatch (compile-time guarantee)
Variable scope
Manually managed, lives in enclosing block
Flow-scoped, only exists where type is guaranteed
JEP
N/A
JEP 394 (Java 16, finalized in Java 17)
2. Basic Pattern Matching
The new syntax is beautifully simple. Instead of writing instanceof Type and then casting, you write instanceof Type variableName. The compiler creates and casts the variable for you, and it is immediately available for use.
// Old way: three steps
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// New way: one step
if (obj instanceof String s) {
System.out.println(s.length()); // s is already a String -- no cast needed
}
// The variable 's' is called a "pattern variable"
// It is automatically:
// 1. Declared (you don't write 'String s = ...')
// 2. Cast (the compiler handles the cast)
// 3. Scoped (it only exists where the instanceof is true)
The variable s in obj instanceof String s is called a pattern variable. It is a binding that the compiler creates for you when the pattern matches. You do not need to declare it separately, and you cannot accidentally cast to the wrong type because the declaration and the type check are a single atomic expression.
More Examples
// Example 1: Working with different number types
public static double toDouble(Object obj) {
if (obj instanceof Integer i) {
return i.doubleValue(); // i is Integer
} else if (obj instanceof Double d) {
return d; // d is Double
} else if (obj instanceof Long l) {
return l.doubleValue(); // l is Long
} else if (obj instanceof String s) {
return Double.parseDouble(s); // s is String
}
throw new IllegalArgumentException("Cannot convert: " + obj);
}
// Example 2: Null-safe usage
Object value = null;
if (value instanceof String s) {
// This block NEVER executes when value is null
// instanceof already returns false for null, so pattern matching
// inherits this behavior -- no NullPointerException possible
System.out.println(s.length());
}
System.out.println("Safe -- no NPE");
// Example 3: Using in a method that processes mixed collections
public static void printDetails(List
Key point about null safety: Pattern matching for instanceof is inherently null-safe. If the object being tested is null, instanceof returns false, and the pattern variable is never bound. This means you can never get a NullPointerException from using a pattern variable inside the if block. This is a significant advantage over manual casting, where forgetting a null check is a common source of bugs.
3. Scope Rules
The scope of a pattern variable is one of the most important — and sometimes surprising — aspects of this feature. Pattern variables use flow scoping, which means the variable is only in scope where the compiler can guarantee the instanceof test succeeded. This is different from traditional block scoping.
Basic if-else Scope
public void demonstrateScope(Object obj) {
// Pattern variable 's' is in scope ONLY inside the if-block
if (obj instanceof String s) {
// s is in scope here -- instanceof was true
System.out.println(s.toUpperCase());
} else {
// s is NOT in scope here -- obj is NOT a String
// System.out.println(s.length()); // COMPILE ERROR
System.out.println("Not a string: " + obj);
}
// s is NOT in scope here either
// System.out.println(s); // COMPILE ERROR
}
Negated instanceof (The “Else” Pattern)
Here is where flow scoping gets interesting. When you negate the instanceof check, the pattern variable flows into the else path — which, in this case, is everything after the if-block:
public void negatedScope(Object obj) {
// Negated pattern: "if obj is NOT a String, exit early"
if (!(obj instanceof String s)) {
// s is NOT in scope here -- obj is NOT a String
System.out.println("Not a string, returning");
return; // <-- early return is KEY
}
// s IS in scope here! Because if we reach this line,
// the negated condition was false, meaning instanceof was TRUE
System.out.println(s.toUpperCase()); // perfectly valid
System.out.println(s.length()); // s is still in scope
}
// This is the "guard clause" pattern -- very common in real code
public String processInput(Object input) {
if (!(input instanceof String s)) {
throw new IllegalArgumentException("Expected String, got: " + input);
}
// s is in scope for the rest of the method
return s.trim().toLowerCase();
}
The guard clause pattern is extremely powerful. It lets you validate the type at the top of a method and then use the pattern variable for the entire remaining method body without nesting everything inside an if-block. This leads to flatter, more readable code.
Scope in Loops
// Pattern variables in loops work as expected
public void processItems(List
Flow Scoping Summary
Pattern
Variable in Scope?
Why
if (x instanceof String s) { ... }
Inside the if-block only
Compiler knows x is String in the if-block
if (!(x instanceof String s)) { return; } use(s);
After the if-block
If we pass the guard, x must be String
if (x instanceof String s) { } else { use(s); }
NOT in the else-block
x is NOT String in the else path
while (x instanceof String s) { ... }
Inside the while body
Loop body only runs when pattern matches
4. Pattern Matching with && and ||
You can combine pattern matching with logical operators, but there are important rules about which combinations work and which do not. Understanding these rules comes down to one question: can the compiler guarantee the pattern variable was bound?
Works: Pattern Matching with && (AND)
The && operator short-circuits: if the left side is false, the right side is never evaluated. This means if instanceof is on the left and passes, the pattern variable is guaranteed to be bound when the right side is evaluated.
// WORKS: instanceof on left, use pattern variable on right
if (obj instanceof String s && s.length() > 5) {
System.out.println("Long string: " + s);
}
// Why this works:
// 1. If obj is not a String, && short-circuits, s.length() is never called
// 2. If obj IS a String, s is bound, and s.length() > 5 is safely evaluated
// Multiple conditions
if (obj instanceof String s && !s.isEmpty() && s.startsWith("http")) {
System.out.println("URL: " + s);
}
// Practical: validate and use in one expression
public void processAge(Object input) {
if (input instanceof Integer age && age >= 0 && age <= 150) {
System.out.println("Valid age: " + age);
} else {
System.out.println("Invalid age input: " + input);
}
}
Does NOT Work: Pattern Matching with || (OR)
The || operator is the opposite: if the left side is true, the right side is never evaluated. This creates a problem: if instanceof is on the left and passes, the right side is skipped -- but if the left side fails, the pattern variable was never bound, so the right side cannot use it.
// DOES NOT COMPILE: pattern variable used with ||
if (obj instanceof String s || s.length() > 5) { // COMPILE ERROR
System.out.println(s);
}
// Why this fails:
// 1. If obj IS a String, || short-circuits, the right side never runs
// -- but s IS bound, so the body could use it
// 2. If obj is NOT a String, s was NEVER bound, but the right side
// tries to use s.length() -- this is unsafe
// The compiler rejects it because s is not definitely bound on all paths
// ALSO DOES NOT COMPILE: two patterns with ||
if (obj instanceof String s || obj instanceof Integer s) { // ERROR
// Even if both bind 's', the types differ (String vs Integer)
}
Rules Summary
Expression
Compiles?
Reason
x instanceof String s && s.length() > 0
Yes
&& guarantees s is bound before right side runs
x instanceof String s && x instanceof Integer i
Yes (but always false)
Syntactically valid, but no object is both String and Integer
x instanceof String s || s.length() > 0
No
s might not be bound when right side runs
x instanceof String s || x instanceof Integer i
Yes, but neither variable is usable in the body
Neither s nor i is definitely bound
!(x instanceof String s) || s.isEmpty()
Yes
If left is false, x IS a String, so s is bound for the right side
The last row is worth studying. The expression !(x instanceof String s) || s.isEmpty() works because: if the negation is false (meaning x instanceof String s is true), then s is bound and the right side s.isEmpty() runs safely. If the negation is true, the || short-circuits and s.isEmpty() is never reached.
5. Pattern Matching in Complex Conditions
In real-world code, you often need to combine type checks with additional validation. Pattern matching integrates cleanly into complex conditional logic.
Nested Conditions
// Nested type checking
public void processNestedData(Object data) {
if (data instanceof Map, ?> map) {
Object value = map.get("payload");
if (value instanceof List> list) {
for (Object element : list) {
if (element instanceof String s) {
System.out.println("Found string in payload: " + s);
}
}
}
}
}
// Real-world example: processing API responses
public void handleApiResponse(Object response) {
if (response instanceof Map, ?> body) {
Object status = body.get("status");
if (status instanceof Integer code && code == 200) {
Object data = body.get("data");
if (data instanceof List> items) {
System.out.println("Success: received " + items.size() + " items");
}
} else if (status instanceof Integer code && code >= 400) {
Object error = body.get("error");
if (error instanceof String message) {
System.out.println("Error " + code + ": " + message);
}
}
}
}
Guard Conditions
A guard condition is an additional boolean check that runs after the type check passes. With &&, you can express this in a single line:
// Guard conditions with pattern matching
public String categorizeInput(Object input) {
if (input instanceof String s && s.isBlank()) {
return "Empty string";
} else if (input instanceof String s && s.length() <= 10) {
return "Short string: " + s;
} else if (input instanceof String s) {
return "Long string: " + s.substring(0, 10) + "...";
} else if (input instanceof Integer i && i < 0) {
return "Negative number: " + i;
} else if (input instanceof Integer i && i == 0) {
return "Zero";
} else if (input instanceof Integer i) {
return "Positive number: " + i;
}
return "Unknown: " + input;
}
// Combining with method calls as guards
public boolean isValidEmail(Object input) {
return input instanceof String email
&& email.contains("@")
&& email.contains(".")
&& email.indexOf("@") < email.lastIndexOf(".")
&& !email.startsWith("@")
&& !email.endsWith(".");
}
// Multiple type checks in sequence with guards
public double calculateDiscount(Object customer, Object orderTotal) {
if (customer instanceof String type && orderTotal instanceof Double total) {
if (type.equals("VIP") && total > 100.0) {
return total * 0.20; // 20% discount
} else if (type.equals("REGULAR") && total > 200.0) {
return total * 0.10; // 10% discount
}
}
return 0.0;
}
Ternary Operator with Pattern Matching
Pattern matching works in any boolean expression, including the ternary operator:
// Pattern matching in ternary -- concise one-liners
Object obj = "Hello World";
String result = obj instanceof String s ? s.toUpperCase() : "not a string";
System.out.println(result); // HELLO WORLD
int length = obj instanceof String s ? s.length() : -1;
System.out.println(length); // 11
// Practical: null-safe type conversion
public static String safeToString(Object obj) {
return obj instanceof String s ? s
: obj != null ? obj.toString()
: "null";
}
6. Pattern Variables and Reassignment
Pattern variables have specific rules about mutability and shadowing that you need to understand.
Pattern Variables Are Not Final (But Should Be Treated As Such)
Unlike what many developers assume, pattern variables are not implicitly final. You can reassign them. However, doing so is almost always a bad idea because it defeats the purpose of the feature and makes code confusing.
// Pattern variables CAN be reassigned (but you shouldn't)
if (obj instanceof String s) {
System.out.println(s); // original value
s = "modified"; // legal, but confusing -- don't do this
System.out.println(s); // "modified"
}
// Why you shouldn't: it breaks the mental model
// When you read 'obj instanceof String s', you expect s == (String) obj
// Reassigning s breaks that expectation
Shadowing Rules
Pattern variables can shadow fields and local variables, but the rules depend on the scope:
public class ShadowingExample {
String name = "field"; // instance field
public void demonstrateShadowing(Object obj) {
String name = "local"; // local variable shadows field
if (obj instanceof String name) {
// COMPILE ERROR in Java 17!
// Pattern variable cannot shadow a local variable
// in the enclosing scope
}
}
public void fieldShadowing(Object obj) {
// Pattern variable CAN shadow an instance field
if (obj instanceof String name) {
System.out.println(name); // pattern variable, not the field
}
System.out.println(name); // the field "field"
}
}
// Avoid shadowing altogether -- use descriptive names
public void processItem(Object item) {
if (item instanceof String text) {
// 'text' is clear and doesn't shadow anything
System.out.println(text.toUpperCase());
}
}
Best Practice: Treat Pattern Variables as Read-Only
Rule
Recommendation
Reassignment
Technically allowed, but avoid it. The variable should represent the casted original object.
Shadowing local variables
Not allowed -- compiler error. Choose a different name.
Shadowing fields
Allowed but discouraged. Use descriptive names to avoid confusion.
Naming convention
Use short, descriptive names: s for String, i for Integer, or full names like message, count
7. Using with equals() and compareTo()
Pattern matching shines when implementing equals() and compareTo() methods, which are two of the most common places where instanceof + cast appears in Java code.
equals() Method
// OLD WAY: equals() with manual instanceof + cast
public class Employee {
private String name;
private int id;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Employee)) return false;
Employee other = (Employee) obj; // redundant cast
return this.id == other.id
&& Objects.equals(this.name, other.name);
}
}
// NEW WAY: equals() with pattern matching
public class Employee {
private String name;
private int id;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof Employee other)) return false;
// 'other' is in scope here because of the negated guard clause
return this.id == other.id
&& Objects.equals(this.name, other.name);
}
}
// Even more concise: single expression
@Override
public boolean equals(Object obj) {
return this == obj
|| (obj instanceof Employee other
&& this.id == other.id
&& Objects.equals(this.name, other.name));
}
compareTo() Method
// OLD WAY: compareTo with manual cast
public class Product implements Comparable {
private String name;
private double price;
@Override
public int compareTo(Object obj) {
if (!(obj instanceof Product)) {
throw new ClassCastException("Expected Product");
}
Product other = (Product) obj;
int priceCompare = Double.compare(this.price, other.price);
return priceCompare != 0 ? priceCompare : this.name.compareTo(other.name);
}
}
// NEW WAY: compareTo with pattern matching
public class Product implements Comparable {
private String name;
private double price;
@Override
public int compareTo(Object obj) {
if (!(obj instanceof Product other)) {
throw new ClassCastException("Expected Product");
}
// 'other' is in scope -- guard clause pattern
int priceCompare = Double.compare(this.price, other.price);
return priceCompare != 0 ? priceCompare : this.name.compareTo(other.name);
}
}
Practical: Value Objects with Pattern Matching
// A complete value object using pattern matching in equals()
public class Money {
private final double amount;
private final String currency;
public Money(double amount, String currency) {
this.amount = amount;
this.currency = currency.toUpperCase();
}
@Override
public boolean equals(Object obj) {
return this == obj
|| (obj instanceof Money other
&& Double.compare(this.amount, other.amount) == 0
&& this.currency.equals(other.currency));
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
@Override
public String toString() {
return currency + " " + String.format("%.2f", amount);
}
}
// Usage
Money a = new Money(29.99, "USD");
Money b = new Money(29.99, "usd");
System.out.println(a.equals(b)); // true
8. Before vs After Comparison
The best way to appreciate pattern matching is to see real code transformations. Here are several before-and-after examples from common patterns in Java codebases.
// BEFORE: Creating objects from untyped input
public Notification createNotification(Object config) {
if (config instanceof Map) {
Map map = (Map) config;
String type = (String) map.get("type");
String message = (String) map.get("message");
if (type.equals("email")) {
return new EmailNotification(message, (String) map.get("to"));
} else if (type.equals("sms")) {
return new SmsNotification(message, (String) map.get("phone"));
}
} else if (config instanceof String) {
String simple = (String) config;
return new LogNotification(simple);
}
throw new IllegalArgumentException("Invalid config");
}
// AFTER: Cleaner with pattern matching
public Notification createNotification(Object config) {
if (config instanceof Map, ?> map) {
String type = (String) map.get("type");
String message = (String) map.get("message");
if ("email".equals(type)) {
return new EmailNotification(message, (String) map.get("to"));
} else if ("sms".equals(type)) {
return new SmsNotification(message, (String) map.get("phone"));
}
} else if (config instanceof String simple) {
return new LogNotification(simple);
}
throw new IllegalArgumentException("Invalid config");
}
Example 4: Implementing toString() for Wrapper Types
// BEFORE: Formatting different types for display
public String formatValue(Object value) {
if (value == null) {
return "null";
} else if (value instanceof String) {
String s = (String) value;
return "\"" + s + "\"";
} else if (value instanceof Double) {
Double d = (Double) value;
return String.format("%.2f", d);
} else if (value instanceof LocalDate) {
LocalDate date = (LocalDate) value;
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
} else if (value instanceof Collection) {
Collection> coll = (Collection>) value;
return "[" + coll.size() + " items]";
}
return value.toString();
}
// AFTER: Every line saved is a line that can't have a bug
public String formatValue(Object value) {
if (value == null) {
return "null";
} else if (value instanceof String s) {
return "\"" + s + "\"";
} else if (value instanceof Double d) {
return String.format("%.2f", d);
} else if (value instanceof LocalDate date) {
return date.format(DateTimeFormatter.ISO_LOCAL_DATE);
} else if (value instanceof Collection> coll) {
return "[" + coll.size() + " items]";
}
return value.toString();
}
Example 5: equals() in a Class Hierarchy
// BEFORE: equals() with getClass() check and manual cast
public class Account {
private final String id;
private final String owner;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Account account = (Account) obj;
return Objects.equals(id, account.id)
&& Objects.equals(owner, account.owner);
}
}
// AFTER: Concise and clear
public class Account {
private final String id;
private final String owner;
@Override
public boolean equals(Object obj) {
return this == obj
|| (obj instanceof Account account
&& Objects.equals(id, account.id)
&& Objects.equals(owner, account.owner));
}
}
// Note: using instanceof instead of getClass() means subclass instances
// can be equal to parent instances. Choose based on your equals() contract.
9. Common Patterns
Pattern matching for instanceof is not just syntactic sugar. It enables several design patterns that were previously verbose or impractical in Java.
Pattern 1: Type-Based Dispatch
This is the most common use case -- routing logic based on an object's runtime type. Before pattern matching, this required either the visitor pattern or chains of instanceof-plus-cast.
The visitor pattern is a classic solution for adding operations to a class hierarchy without modifying the classes. But it is verbose, requires double dispatch, and is hard to understand. Pattern matching provides a simpler alternative for many use cases.
// OLD WAY: Full visitor pattern (verbose)
interface ShapeVisitor {
double visit(Circle c);
double visit(Rectangle r);
double visit(Triangle t);
}
class AreaCalculator implements ShapeVisitor {
public double visit(Circle c) { return Math.PI * c.radius() * c.radius(); }
public double visit(Rectangle r) { return r.width() * r.height(); }
public double visit(Triangle t) { return 0.5 * t.base() * t.height(); }
}
// Each shape class needs an accept() method:
// public double accept(ShapeVisitor v) { return v.visit(this); }
// Usage:
// double area = shape.accept(new AreaCalculator());
// NEW WAY: Pattern matching -- no visitor interface needed
public static double calculateArea(Shape shape) {
if (shape instanceof Circle c) {
return Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle r) {
return r.width() * r.height();
} else if (shape instanceof Triangle t) {
return 0.5 * t.base() * t.height();
}
throw new IllegalArgumentException("Unknown shape: " + shape);
}
// Usage:
// double area = calculateArea(shape);
// No visitor interface, no accept() methods, no double dispatch
Pattern 3: Polymorphic Utility Methods
Sometimes you need to operate on objects from third-party libraries where you cannot add methods. Pattern matching makes this straightforward:
// Utility: safely extract a string representation from various types
public final class JsonUtils {
public static String toJsonValue(Object value) {
if (value == null) {
return "null";
} else if (value instanceof String s) {
return "\"" + escapeJson(s) + "\"";
} else if (value instanceof Number n) {
return n.toString();
} else if (value instanceof Boolean b) {
return b.toString();
} else if (value instanceof Collection> coll) {
return coll.stream()
.map(JsonUtils::toJsonValue)
.collect(Collectors.joining(", ", "[", "]"));
} else if (value instanceof Map, ?> map) {
return map.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\": " + toJsonValue(e.getValue()))
.collect(Collectors.joining(", ", "{", "}"));
}
return "\"" + value.toString() + "\"";
}
private static String escapeJson(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\t", "\\t");
}
}
// Usage
System.out.println(JsonUtils.toJsonValue("hello")); // "hello"
System.out.println(JsonUtils.toJsonValue(42)); // 42
System.out.println(JsonUtils.toJsonValue(List.of(1, 2, 3))); // [1, 2, 3]
Pattern 4: Adapter / Converter Pattern
// Converting between different date/time representations
public static LocalDateTime toLocalDateTime(Object input) {
if (input instanceof LocalDateTime ldt) {
return ldt;
} else if (input instanceof LocalDate ld) {
return ld.atStartOfDay();
} else if (input instanceof Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
} else if (input instanceof Date date) {
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
} else if (input instanceof Long epochMillis) {
return LocalDateTime.ofInstant(
Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault()
);
} else if (input instanceof String s) {
return LocalDateTime.parse(s, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
throw new IllegalArgumentException(
"Cannot convert " + input.getClass().getSimpleName() + " to LocalDateTime"
);
}
// Usage
LocalDateTime dt1 = toLocalDateTime(LocalDate.of(2024, 1, 15));
LocalDateTime dt2 = toLocalDateTime(Instant.now());
LocalDateTime dt3 = toLocalDateTime("2024-01-15T10:30:00");
LocalDateTime dt4 = toLocalDateTime(1705312200000L);
10. Best Practices
Pattern matching for instanceof is a tool, not a silver bullet. Here are guidelines for using it effectively.
When to Use Pattern Matching
Scenario
Use Pattern Matching?
Reasoning
Processing untyped data (JSON, configs)
Yes
You are dealing with Object types and need to dispatch by runtime type
Implementing equals()
Yes
Perfect fit -- eliminates the standard instanceof + cast boilerplate
Exception handling with specific types
Yes
Cleaner extraction of exception-specific information
Third-party types you cannot modify
Yes
You cannot add polymorphic methods, so type dispatch is your only option
Your own class hierarchy
Maybe
Consider polymorphism first. Pattern matching is a fallback, not a default.
Replacing polymorphic methods
No
If you own the types and can add methods, polymorphism is almost always better
Deeply nested type checks
Cautiously
If you need 3+ levels of nesting, rethink the design
Pattern Matching vs Polymorphism
The question every developer asks: "Should I use pattern matching or polymorphism?" The answer depends on where the operation is defined and who owns the types.
// POLYMORPHISM: when you own the types and the operation is core behavior
// Each shape knows how to calculate its own area
interface Shape {
double area();
}
record Circle(double radius) implements Shape {
public double area() { return Math.PI * radius * radius; }
}
record Rectangle(double width, double height) implements Shape {
public double area() { return width * height; }
}
// Adding a new shape? Just implement the interface -- no other code changes.
// This is the Open/Closed Principle in action.
// PATTERN MATCHING: when you DON'T own the types or the operation is external
// Exporting shapes to SVG -- this is NOT core shape behavior
public static String toSvg(Shape shape) {
if (shape instanceof Circle c) {
return String.format("", c.radius());
} else if (shape instanceof Rectangle r) {
return String.format("",
r.width(), r.height());
}
throw new IllegalArgumentException("Unsupported shape");
}
// The SVG export is an external concern -- it doesn't belong in the Shape interface.
// Pattern matching is the right tool here.
Readability Guidelines
Use short variable names for short scopes:if (obj instanceof String s) is fine when the block is 1-3 lines. For longer blocks, use descriptive names like message or errorText.
Prefer guard clauses over nesting: Use the negated if (!(obj instanceof Type t)) return; pattern to keep code flat.
Do not chain more than 5-6 instanceof checks: If you have more, it is a code smell. Consider a Map-based dispatch, a strategy pattern, or refactoring the type hierarchy.
Keep guard conditions simple:if (obj instanceof String s && s.length() > 5) is readable. if (obj instanceof String s && s.length() > 5 && !s.isBlank() && s.matches("^[a-z]+$")) should be extracted into a method.
Combine with sealed classes for exhaustive checking: In Java 21+, sealed classes + pattern matching in switch gives you compile-time exhaustiveness guarantees.
Migration Strategy
When migrating an existing codebase to use pattern matching:
Start with equals() methods -- they are the most mechanical and safest to convert.
Convert utility/helper methods next -- methods that process Object parameters.
Tackle type-dispatch chains last -- these may need broader refactoring if the design should use polymorphism instead.
Do not force it -- if the old code is clear and correct, leaving it alone is a valid choice. Pattern matching is about improving readability, not rewriting everything.
Quick Reference
Feature
Detail
Syntax
if (obj instanceof Type variable) { ... }
JEP
JEP 394 -- finalized in Java 16, permanent in Java 17 LTS
Null safety
instanceof returns false for null, so pattern variables are never null
Scope
Flow-scoped: available where the compiler can prove the match succeeded
Works with &&
Yes -- obj instanceof String s && s.length() > 5
Works with ||
Limited -- the pattern variable is not in scope on the right side of ||
Reassignment
Allowed but strongly discouraged
Works in switch
Preview in Java 17, finalized in Java 21
Works with generics
Yes -- obj instanceof List> list (but not List due to erasure)
IDE support
IntelliJ, Eclipse, and VS Code all offer automated refactoring to convert old patterns