Java 17 Pattern Matching for instanceof

1. Introduction

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 items) {
    for (Object item : items) {
        if (item instanceof String s) {
            System.out.println("String of length " + s.length() + ": " + s);
        } else if (item instanceof Integer i) {
            System.out.println("Integer, even=" + (i % 2 == 0) + ": " + i);
        } else if (item instanceof List list) {
            System.out.println("List with " + list.size() + " elements");
        } else if (item instanceof Map map) {
            System.out.println("Map with " + map.size() + " entries");
        } else {
            System.out.println("Unknown type: " + item.getClass().getSimpleName());
        }
    }
}

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 items) {
    for (Object item : items) {
        if (item instanceof String s) {
            // s is scoped to this iteration of the loop
            System.out.println("String: " + s.toUpperCase());
        }
        // s is out of scope here
    }
}

// Using the negated pattern with continue
public void processStringsOnly(List items) {
    for (Object item : items) {
        if (!(item instanceof String s)) {
            continue;  // skip non-strings
        }
        // s is in scope for the rest of this loop iteration
        System.out.println("Processing: " + s.trim());
    }
}

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.

Example 1: Processing Events

// BEFORE: Event processing with manual casts
public void handleEvent(Event event) {
    if (event instanceof ClickEvent) {
        ClickEvent click = (ClickEvent) event;
        System.out.println("Click at " + click.getX() + ", " + click.getY());
    } else if (event instanceof KeyEvent) {
        KeyEvent key = (KeyEvent) event;
        System.out.println("Key pressed: " + key.getKeyCode());
    } else if (event instanceof ScrollEvent) {
        ScrollEvent scroll = (ScrollEvent) event;
        System.out.println("Scroll delta: " + scroll.getDelta());
    }
}

// AFTER: Clean and concise
public void handleEvent(Event event) {
    if (event instanceof ClickEvent click) {
        System.out.println("Click at " + click.getX() + ", " + click.getY());
    } else if (event instanceof KeyEvent key) {
        System.out.println("Key pressed: " + key.getKeyCode());
    } else if (event instanceof ScrollEvent scroll) {
        System.out.println("Scroll delta: " + scroll.getDelta());
    }
}

Example 2: Exception Handling

// BEFORE: Extracting details from exception causes
public String getErrorMessage(Throwable t) {
    if (t instanceof SQLException) {
        SQLException sqlEx = (SQLException) t;
        return "SQL Error " + sqlEx.getErrorCode() + ": " + sqlEx.getMessage();
    } else if (t instanceof FileNotFoundException) {
        FileNotFoundException fnf = (FileNotFoundException) t;
        return "File not found: " + fnf.getMessage();
    } else if (t instanceof NumberFormatException) {
        NumberFormatException nfe = (NumberFormatException) t;
        return "Invalid number: " + nfe.getMessage();
    }
    return "Error: " + t.getMessage();
}

// AFTER: Half the lines, same logic
public String getErrorMessage(Throwable t) {
    if (t instanceof SQLException sqlEx) {
        return "SQL Error " + sqlEx.getErrorCode() + ": " + sqlEx.getMessage();
    } else if (t instanceof FileNotFoundException fnf) {
        return "File not found: " + fnf.getMessage();
    } else if (t instanceof NumberFormatException nfe) {
        return "Invalid number: " + nfe.getMessage();
    }
    return "Error: " + t.getMessage();
}

Example 3: Factory Method with Validation

// 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.

// Type-based dispatch: processing different message types
public class MessageProcessor {

    public void process(Object message) {
        if (message instanceof TextMessage text) {
            handleText(text);
        } else if (message instanceof ImageMessage image) {
            handleImage(image);
        } else if (message instanceof VideoMessage video) {
            handleVideo(video);
        } else if (message instanceof FileMessage file && file.getSize() < 10_000_000) {
            handleSmallFile(file);
        } else if (message instanceof FileMessage file) {
            handleLargeFile(file);
        } else {
            throw new UnsupportedOperationException(
                "Unknown message type: " + message.getClass().getSimpleName()
            );
        }
    }

    private void handleText(TextMessage msg) {
        System.out.println("Text: " + msg.getContent());
    }

    private void handleImage(ImageMessage msg) {
        System.out.printf("Image: %dx%d, format=%s%n",
            msg.getWidth(), msg.getHeight(), msg.getFormat());
    }

    private void handleVideo(VideoMessage msg) {
        System.out.printf("Video: %s, duration=%ds%n",
            msg.getTitle(), msg.getDuration());
    }

    private void handleSmallFile(FileMessage msg) {
        System.out.println("Small file, processing inline: " + msg.getName());
    }

    private void handleLargeFile(FileMessage msg) {
        System.out.println("Large file, queueing for async: " + msg.getName());
    }
}

Pattern 2: Visitor Pattern Replacement

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:

  1. Start with equals() methods -- they are the most mechanical and safest to convert.
  2. Convert utility/helper methods next -- methods that process Object parameters.
  3. Tackle type-dispatch chains last -- these may need broader refactoring if the design should use polymorphism instead.
  4. 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



Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

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