DEV Community

Cover image for Modern Java Pattern Matching: Transform Type Checking Into Clean, Compiler-Verified Code Logic
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Modern Java Pattern Matching: Transform Type Checking Into Clean, Compiler-Verified Code Logic

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

When I write code, I want it to be clear. I want it to be safe. For years, Java required a lot of ceremony to check what kind of object I was holding and to pull data out of it. It felt like getting a package and having to shake it, weigh it, and then carefully cut it open with three different tools just to see what's inside. Modern Java offers a better way. It gives me pattern matching and sealed classes. These aren't just new keywords; they are tools that change how I design and reason about my programs. They let me write code where my intent is obvious, and where the compiler can help me catch mistakes before they ever run.

Think of it like sorting mail. In the old way, I'd get an item, check if it's a letter, then cast it to a letter to read the address, then process it. Then I'd check if it's a package, cast it to a package, check its weight, and process it. The new way is more direct. I look at the item and in one glance, I know, "This is a letter with this address," and I act. That's pattern matching. Now, imagine if my mailroom only ever receives letters, packages, and catalogs. If I declare that upfront, then I can be confident my sorting process is complete. That's what sealed classes do. Together, they make my code a precise and verifiable set of instructions.

Let's start with the first technique, which is the foundation of this shift. It's about replacing clunky type checking with a single, clean expression. Before, using instanceof was a two-step dance: check the type, then cast.

// The old, verbose way
Object response = getApiResponse();
if (response instanceof Success) {
    Success successResponse = (Success) response;
    processData(successResponse.getData());
}
Enter fullscreen mode Exit fullscreen mode

Now, those two steps fuse into one. The check and the cast happen together, and a new variable is born, ready to use. This variable's scope is smart, too. It exists only where the pattern matches, which prevents me from accidentally using a String variable when the object might be an Integer.

// The new, streamlined way
Object response = getApiResponse();
if (response instanceof Success success) {
    // 'success' is a variable of type Success, right here.
    processData(success.getData());
}
// 'success' does not exist out here. That's good!
Enter fullscreen mode Exit fullscreen mode

This becomes even more powerful when handling multiple outcomes. The code reads like a clear story of possibilities.

public void handleResult(Object result) {
    if (result instanceof Success s) {
        System.out.println("Success: " + s.value());
    } else if (result instanceof Failure f) {
        System.out.println("Failed with: " + f.errorMessage());
        alertUser(f.errorMessage());
    } else if (result == null) {
        System.out.println("Result is absent.");
    }
}
Enter fullscreen mode Exit fullscreen mode

The second technique elevates this idea from simple if statements to the switch expression. Traditional switch was limited to simple types like integers and strings. Now, I can switch on the type of any object and deconstruct it on the spot. This turns a switch from a simple jump table into a powerful query into the shape of my data.

The syntax becomes declarative. I say what the object is, and I immediately say what to do with it. There's no need for break statements; each case is a self-contained arrow expression that yields a value.

// Switching on type and extracting values directly
String describe(Object o) {
    return switch (o) {
        case Integer i -> "It's an integer: " + i;
        case String s  -> "It's a string with length: " + s.length();
        case int[] arr -> "It's an array with " + arr.length + " elements";
        case null      -> "It's nothing (null)";
        default        -> "It's something else: " + o.getClass().getSimpleName();
    };
}
Enter fullscreen mode Exit fullscreen mode

I can use this to assign variables, return from methods, or even as part of a larger expression. It feels functional and direct.

// Using a switch expression to compute a value
int processedValue = switch (rawInput) {
    case String s -> Integer.parseInt(s.trim());
    case Integer i -> i;
    case Number n -> n.intValue();
    default -> throw new IllegalArgumentException("Unsupported type");
};
Enter fullscreen mode Exit fullscreen mode

The real power of this, however, is locked away until we control the universe of possible types. That's where the third technique comes in: sealed classes. In my experience, a common source of bugs is when a method designed to handle a fixed set of types encounters a new, unexpected one. With sealed classes, I can define that fixed set explicitly.

I declare an interface or class and explicitly say, "Only these classes are allowed to implement or extend this." It's like putting a fence around a family of types.

// Defining a closed hierarchy for payment methods
public sealed interface PaymentMethod
    permits CreditCard, PayPal, BankTransfer {

    BigDecimal getAmount();
    boolean process();
}

// The compiler knows these are the only three possibilities.
public final record CreditCard(String number, String expiry) implements PaymentMethod { ... }
public final record PayPal(String email) implements PaymentMethod { ... }
public final record BankTransfer(String reference) implements PaymentMethod { ... }
Enter fullscreen mode Exit fullscreen mode

By doing this, I give the compiler a crucial piece of knowledge. It now knows the complete list of PaymentMethod types. This knowledge supercharges pattern matching in switch expressions, enabling the fourth technique: exhaustive matching.

When I switch over a sealed type, the compiler can check if I've handled every permitted subclass. If I forget one, it's a compile-time error. This eliminates an entire category of runtime bugs. I don't need a sloppy default clause that hides my oversight.

// The compiler verifies this switch is complete.
String processPayment(PaymentMethod method) {
    return switch (method) {
        case CreditCard cc -> "Charging card ending in " + cc.number().substring(cc.number().length() - 4);
        case PayPal pp     -> "Requesting payment from " + pp.email();
        case BankTransfer bt -> "Processing transfer with ref: " + bt.reference();
        // No default! The compiler knows we've covered CreditCard, PayPal, and BankTransfer.
    };
}
Enter fullscreen mode Exit fullscreen mode

This is a profound change. My design decisions—the sealed hierarchy—are now encoded in the type system and enforced by the compiler. The code isn't just instructions; it's a verified proof of completeness. If I later add a new CryptoWallet class to the permits list, the compiler will immediately flag every switch that now needs updating. It turns a runtime liability into a compile-time maintenance task.

The fifth technique builds on all of these, allowing me to drill down into data structures in a single step: record patterns and deconstruction. Records are transparent carriers of data. Pattern matching allows me to match not just on the type, but also to break it open and grab its internal components directly in the pattern itself.

Instead of matching an object and then calling its accessor methods, I can deconstruct it right in the case label.

// Defining some simple records
record Point(int x, int y) {}
record Circle(Point center, int radius) {}
record Rectangle(Point topLeft, Point bottomRight) {}

// Old way: match, then extract.
String oldDescribe(Shape s) {
    if (s instanceof Circle) {
        Circle c = (Circle) s;
        Point center = c.center();
        return "Circle at (" + center.x() + ", " + center.y() + ")";
    }
    // ... more code
}

// New way: match and deconstruct simultaneously.
String newDescribe(Shape s) {
    return switch (s) {
        case Circle(Point center, int r) ->
            "Circle at (" + center.x() + ", " + center.y() + ") with radius " + r;
        case Rectangle(Point tl, Point br) ->
            "Rectangle from (" + tl.x() + ", " + tl.y() + ") to (" + br.x() + ", " + br.y() + ")";
        default -> "Unknown shape";
    };
}
Enter fullscreen mode Exit fullscreen mode

This becomes incredibly expressive with nested patterns. I can match a Circle and in the same breath, deconstruct its Point center to get the x and y coordinates.

// Nested pattern: drilling down into data in one line.
boolean isOnAxis(Object obj) {
    return switch (obj) {
        case Point(int x, int y) -> x == 0 || y == 0;
        case Circle(Point(int x, int y), int r) -> x == 0 || y == 0;
        default -> false;
    };
}
// Checks if a Point is on the X or Y axis, or if a Circle's center is on an axis.
Enter fullscreen mode Exit fullscreen mode

Finally, patterns can be made even more precise with guard clauses, expressed using when. A guard is a boolean condition attached to a pattern. The match only succeeds if the type matches and the condition is true.

This lets me express complex logic within the clean structure of a switch, keeping related conditions together instead of spreading them across multiple lines of if statements inside a case block.

// Using guards to refine matches
String analyzeNumber(Number n) {
    return switch (n) {
        case Integer i when i > 0 -> "Positive integer";
        case Integer i when i == 0 -> "Zero";
        case Integer i -> "Negative integer"; // i < 0 is now the only remaining possibility
        case Double d when d.isNaN() -> "Not a number";
        case Double d when d > 1.0e6 -> "A very large double";
        case Double d -> "A regular double";
        case null -> "Null number";
        default -> "Some other number type";
    };
}
Enter fullscreen mode Exit fullscreen mode

In practice, I find this incredibly useful for validation. I can combine type matching, deconstruction, and a business rule in a single, readable case.

public Optional<String> validateTransaction(Object tx) {
    return switch (tx) {
        case Transfer(Account from, Account to, BigDecimal amt)
                when from.hasSufficientFunds(amt) && !from.isFrozen() ->
                    Optional.of("Transfer approved.");
        case Transfer(Account from, _, _) when from.isFrozen() ->
                    Optional.of("Transfer rejected: account frozen.");
        case Transfer t ->
                    Optional.of("Transfer rejected: insufficient funds.");
        case Deposit d ->
                    Optional.of("Deposit processed.");
        default ->
                    Optional.empty();
    };
}
Enter fullscreen mode Exit fullscreen mode

Bringing these five techniques together changes how I approach Java code. I start by designing with sealed interfaces to define clear, bounded domains—like Shape, PaymentMethod, Result<T>, or Command. This structure is self-documenting. Then, I use pattern matching in switch expressions to define the behavior for each case. The compiler checks my work, ensuring I haven't missed a scenario. Record patterns let me work with the data inside those types fluently, without tedious getter calls. Guards allow me to handle special conditions right where the type is identified.

The result is code that is more than just functional. It's expressive. It clearly states the rules of the domain. It turns what used to be a sprawling mass of instanceof checks, casts, and if-else chains into a well-organized, table-like structure. When I read it, I can see all the possibilities laid out clearly. When the compiler reads it, it can prove they are all handled. This partnership between me and the compiler leads to software that is not only easier to write but also fundamentally more robust. It lets me spend less time writing boilerplate and debugging ClassCastException errors, and more time focusing on the actual problem I want to solve.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)