Pattern Matching

The Hook: Imagine running a massive sorting facility. Packages arrive constantly. To process a package, a worker must first check its shape (is it a fragile box or a durable tube?), explicitly open it according to that shape, and then finally pull out the contents. In software, this is exactly what we’ve done for decades: check a type, explicitly cast it, and extract data. It’s tedious, error-prone, and clutters the core logic.

Pattern matching is the modern Java solution. It acts like an intelligent scanner: in one unified step, it identifies the “shape” of the data (the type or record structure), opens it up, and hands you the contents ready to use. This entirely eliminates the redundant casting that has plagued Java since its inception.

Mnemonic - S.O.S. (Shape, Open, Scope)

  • Shape: Check the structure (e.g., instanceof String).
  • Open: Extract the value directly into a binding variable.
  • Scope: The variable is strictly flow-scoped to where the match is definitively true.

1. The instanceof Pattern

For 25 years, Java developers wrote this boilerplate:

// The Old Way
if (obj instanceof String) {
    String s = (String) obj; // Explicit cast (redundant!)
    System.out.println(s.length());
}

Now, Java combines the check and the cast:

// The Modern Way (Java 16+)
if (obj instanceof String s) {
    // 's' is the "Binding Variable"
    // It is ONLY in scope if the check passes!
    System.out.println(s.length());
}

Deep Dive: Flow Scoping

The variable s is flow-scoped. It exists only where the compiler can guarantee the pattern matched.

// 1. In the same condition
if (obj instanceof String s && s.length() > 5) {
    // Valid: s exists here
}

// 2. Inverted Logic
if (!(obj instanceof String s)) {
    return; // If it's NOT a string, we leave
}
// Valid: s exists here because we survived the return!
System.out.println(s.toUpperCase());

2. Pattern Matching for Switch

Java 21 allows using patterns in switch cases. This replaces long chains of if-else-if.

Interactive Demo: Pattern Lab

Simulate the Dispatch

Object o = input; return switch (o) {   case null → "It is null";   case String s → "String: " + s;   case Integer i → "Int: " + i;   default → "Unknown type"; };
Result: ...

Performance Reality: O(1) vs O(N)

Why use switch instead of if-else?

  • If-Else Chain: The JVM must check each condition sequentially. If you have 20 types, the last one takes 20 checks. This is O(N).
  • Switch: The JVM uses indy (invokedynamic) or optimized bytecode (type switch) to jump directly to the correct label. It is roughly O(1) (constant time), regardless of how many cases you have.

Real-World War Story: The JSON Parser

Scenario: You are writing a JSON parser. The AST (Abstract Syntax Tree) can have nodes that are Objects, Arrays, Strings, or Numbers.

Before Pattern Matching, developers often resorted to the verbose Visitor Pattern (scattering logic across many classes) or a massive, slow If-Else Chain:

// The Old Way (If-Else Hell)
public String formatJsonNode(JsonNode node) {
    if (node instanceof JsonObject) {
        JsonObject obj = (JsonObject) node;
        return "Object with " + obj.keys().size() + " keys";
    } else if (node instanceof JsonArray) {
        JsonArray arr = (JsonArray) node;
        return "Array with " + arr.elements().size() + " items";
    } else if (node instanceof JsonString) {
        JsonString str = (JsonString) node;
        return "String: " + str.value();
    }
    return "Unknown";
}

With Pattern Matching for Switch, the logic becomes localized, declarative, and fast:

// The Modern Way (Java 21+)
public String formatJsonNode(JsonNode node) {
    return switch (node) {
        case JsonObject obj -> "Object with " + obj.keys().size() + " keys";
        case JsonArray arr  -> "Array with " + arr.elements().size() + " items";
        case JsonString str -> "String: " + str.value();
        case null           -> "Null node";
        default             -> "Unknown node type";
    };
}

Architectural Comparison

Approach Readability Performance Boilerplate Extensibility (Adding new types)
If-Else Chain Poor (Cluttered with casts) O(N) (Sequential checks) High Easy to add in one place, but compiler won’t warn if missing
Visitor Pattern Poor (Logic scattered across classes) O(1) (Double dispatch) Very High Hard (Requires modifying all visitor interfaces/classes)
Switch Pattern Excellent (Declarative, no casts) O(1) (Optimized jump table) Low Compiler Exhaustiveness Checks (if using sealed classes)

3. Record Patterns (Destructuring)

This is where it gets powerful. You can match a Record and extract its fields in one go.

record Point(int x, int y) {}
record Circle(Point center, int radius) {}

Object obj = new Circle(new Point(0, 0), 10);

if (obj instanceof Circle(Point p, int r)) {
    // Extracted p and r directly
    System.out.println("Radius: " + r);
}

Nested Patterns

You can go deeper! This eliminates the “train wreck” of getCenter().getX().

if (obj instanceof Circle(Point(int x, int y), int r)) {
    // Extracted x, y, and r directly!
    System.out.println("Circle at " + x + "," + y);
}

4. Guarded Patterns (when)

Sometimes type checking isn’t enough. You need to check values too.

Before (Nested If)

case String s -> {
    if (s.length() > 5) yield "Long string";
    else yield "Short string";
}

After (Guarded Pattern)

Use the when keyword to add a boolean condition to the pattern.

return switch (obj) {
    // Specific case first!
    case String s when s.length() > 5 -> "Long string";
    // General case second
    case String s                     -> "Short string";
    case Integer i                    -> "Number";
    default                           -> "Unknown";
};

[!IMPORTANT] The compiler forces you to put specific cases before general cases. If you swap the order above, the compiler will error: “This case is dominated by a preceding case.”

Real-World Example: HTTP Response Router

Combine Record Patterns and Guarded Patterns to route HTTP responses cleanly without deeply nested if-statements.

record HttpResponse(int statusCode, String body) {}

public void handleResponse(HttpResponse response) {
    switch (response) {
        case HttpResponse r when r.statusCode() >= 200 && r.statusCode() < 300
            -> System.out.println("Success: " + r.body());
        case HttpResponse r when r.statusCode() >= 400 && r.statusCode() < 500
            -> System.out.println("Client Error: " + r.statusCode());
        case HttpResponse r when r.statusCode() >= 500
            -> System.out.println("Server Error. Retrying...");
        case HttpResponse r
            -> System.out.println("Other status: " + r.statusCode());
    }
}

5. Null Handling

In the old switch, passing null threw a NullPointerException immediately. In the modern switch, you can handle null explicitly.

switch (obj) {
    case null      -> System.out.println("It is null");
    case String s  -> System.out.println("It is a string");
    default        -> System.out.println("Something else");
}

If you don’t have a case null, and the input is null, it will still throw NPE (to preserve backward compatibility).


6. Summary

  • instanceof T var: Checks type and casts to var (flow-scoped).
  • Switch Patterns: High-performance dispatch based on type.
  • Record Patterns: Deconstruct nested data structures easily.
  • Guarded Patterns (when): Refine matches with boolean logic.
  • Best Practice: Use pattern matching to replace Visitor patterns. It makes the code more localized and easier to read.

[!TIP] Use Pattern Matching to simplify JSON parsing logic or AST (Abstract Syntax Tree) traversal. It makes code readable and declarative.