DEV Community

Cover image for Java Pattern Matching: Simplify Complex Logic with Modern Syntax and Type Safety
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Java Pattern Matching: Simplify Complex Logic with Modern Syntax and Type Safety

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!

For years, writing conditional logic in Java felt like a chore. We've all written those long chains of if and instanceof, followed by the explicit cast. It was verbose, cluttered, and easy to get wrong. A simple type check could sprawl across several lines. Something fundamental has changed. Modern Java introduces a clearer, more direct way to work with our data. This is pattern matching. It lets you test a value against a specific structure and extract its components in one go. Think of it as asking a question and getting the answer ready to use, all at once.

Let's start with the most common pain point: checking an object's type. Before, you had to do two steps. First, check the type. Second, cast it to that type. Pattern matching combines these.

// The old, repetitive way
Object response = getApiResponse();
if (response instanceof Success) {
    Success success = (Success) response;
    processData(success.getData());
}

// The new, combined way
Object response = getApiResponse();
if (response instanceof Success success) {
    // The variable 'success' is already typed and ready here
    processData(success.getData());
}
Enter fullscreen mode Exit fullscreen mode

See the difference? The new syntax, instanceof Success success, does both. It asks, "Is this a Success object?" If yes, it immediately gives us a variable named success of that type. We skip the cast line entirely. This makes the code cleaner and easier to read. It also works neatly with logical operators.

// Using it with conditions
if (!(obj instanceof String text) || text.isBlank()) {
    return "default";
}
// 'text' is available here, and we know it's a non-blank String
return text.toUpperCase();
Enter fullscreen mode Exit fullscreen mode

The real power grows when we move beyond simple if statements. The switch expression, another modern feature, becomes a perfect partner for pattern matching. It transforms a switch from a simple value checker into a powerful data inspector.

Imagine you have different shape objects. The old way might use a series of if-else statements. A pattern matching switch is more structured and expressive.

// Using switch to handle different types
String describe(Object shape) {
    return switch (shape) {
        case Circle c -> "It's a circle of radius " + c.radius();
        case Rectangle r -> "It's a rectangle, area " + (r.width() * r.height());
        case Triangle t -> "It's a triangle with base " + t.base();
        case null -> "It's nothing (null)";
        default -> "It's some other shape";
    };
}
Enter fullscreen mode Exit fullscreen mode

This is already more readable. But we can add conditions right inside the case label. These are called guarded patterns.

String checkNumber(Object obj) {
    return switch (obj) {
        case Integer i when i > 100 -> "Big number: " + i;
        case Integer i when i == 0 -> "Zero";
        case Integer i -> "Small number: " + i; // Any other Integer
        case String s when s.length() > 5 -> "A long word";
        case String s -> "A short word";
        default -> "Something else";
    };
}
Enter fullscreen mode Exit fullscreen mode

The when i > 100 clause adds a filter. It only matches an Integer if that extra condition is true. This lets you express complex logic in a very organized, top-down way. The compiler also helps you. If you use a switch expression (with the arrow -> syntax), it ensures you cover all possible input values or have a default case.

This brings us to a crucial companion feature: sealed types. A sealed type gives you control. You can declare an interface or class and say, "Only these specific classes are allowed to implement or extend me." This is a promise to the compiler.

// Defining a sealed hierarchy for payment results
public sealed interface PaymentResult
    permits PaymentApproved, PaymentDeclined, PaymentPending {}

public record PaymentApproved(String txId, BigDecimal amount) implements PaymentResult {}
public record PaymentDeclined(String txId, String reason) implements PaymentResult {}
public record PaymentPending(String txId) implements PaymentResult {}
Enter fullscreen mode Exit fullscreen mode

By sealing PaymentResult, we tell Java that only three possible outcomes exist. Now, look at what happens in a switch.

String processPayment(PaymentResult result) {
    return switch (result) {
        case PaymentApproved approved ->
            "Approved! TX: " + approved.txId() + " for $" + approved.amount();
        case PaymentDeclined declined ->
            "Declined. TX: " + declined.txId() + ". Reason: " + declined.reason();
        case PaymentPending pending ->
            "Pending for TX: " + pending.txId();
        // No default needed! The compiler knows these are all the possibilities.
    };
}
Enter fullscreen mode Exit fullscreen mode

This is a powerful safety net. If someone later adds a new type to the permits list, like PaymentRefunded, the compiler will immediately flag every switch that processes PaymentResult. It will say, "You're missing a case!" This prevents runtime errors and makes updating code much safer. It turns a runtime problem into a compile-time error, which is where we want to find issues.

Records, Java's way of creating simple data carriers, work beautifully with pattern matching. We can deconstruct them. That means we can break a record object down into its components directly in the pattern.

// Define some records
record Coordinate(int x, int y) {}
record Region(String name, Coordinate topLeft, Coordinate bottomRight) {}

// Deconstructing a record in a pattern
String analyze(Object obj) {
    return switch (obj) {
        case Coordinate(int x, int y) -> 
            "Coordinate at (" + x + ", " + y + ")";
        case Region(String n, Coordinate tl, Coordinate br) ->
            "Region '" + n + "' from " + tl.x() + "," + tl.y() +
            " to " + br.x() + "," + br.y();
        default -> "Unknown object";
    };
}
Enter fullscreen mode Exit fullscreen mode

In case Coordinate(int x, int y), we don't just match a Coordinate. We also pull out its x and y values and give them names. We can go further with nested patterns.

// Nested pattern: breaking down the records inside records
double calculateDiagonal(Object obj) {
    return switch (obj) {
        case Region(_, Coordinate(var x1, var y1), Coordinate(var x2, var y2)) -> {
            double width = x2 - x1;
            double height = y2 - y1;
            yield Math.sqrt(width * width + height * height);
        }
        default -> 0.0;
    };
}
Enter fullscreen mode Exit fullscreen mode

Here, Coordinate(var x1, var y1) is a pattern nested inside the Region pattern. The var keyword lets us declare the components without repeating their type. In one clear line, we extract four pieces of data: x1, y1, x2, and y2. This is incredibly concise for working with complex, nested data.

How does this change everyday code? Let me give you a practical example I see often: processing semi-structured data, like configurations or simple trees.

// A tree structure for expressions
sealed interface Expr permits ConstantExpr, AddExpr, MultiplyExpr {}
record ConstantExpr(int value) implements Expr {}
record AddExpr(Expr left, Expr right) implements Expr {}
record MultiplyExpr(Expr left, Expr right) implements Expr {}

// Evaluating the expression with pattern matching
int evaluate(Expr e) {
    return switch (e) {
        case ConstantExpr(int v) -> v;
        case AddExpr(Expr l, Expr r) -> evaluate(l) + evaluate(r);
        case MultiplyExpr(Expr l, Expr r) -> evaluate(l) * evaluate(r);
    };
}
Enter fullscreen mode Exit fullscreen mode

The evaluate function is straightforward. It mirrors the structure of the data. Each case handles one shape of the data and pulls out the relevant parts. The recursive calls for AddExpr and MultiplyExpr are clean and easy to follow. Without pattern matching, this code would be full of instanceof checks and casts, obscuring the simple logic.

Another common use is validation. Instead of a series of separate if statements checking different conditions, you can use a pattern matching switch to collect all errors.

record UserSignup(String username, String email, Integer age) {}

List<String> validate(UserSignup user) {
    return switch (user) {
        case UserSignup(String u, String e, Integer a) when u == null || u.trim().isEmpty() ->
            List.of("Username cannot be empty");
        case UserSignup(String u, String e, Integer a) when e == null || !e.contains("@") ->
            List.of("A valid email is required");
        case UserSignup(String u, String e, Integer a) when a == null || a < 16 ->
            List.of("You must be at least 16 years old");
        case UserSignup u -> 
            Collections.emptyList(); // All good
    };
}
Enter fullscreen mode Exit fullscreen mode

This groups each validation rule with the data it validates. It's declarative. You state the rule: "If the user has a username that is null or empty, then report this error." The structure makes it simple to add or reorder rules.

It's important to remember the order. The switch checks cases from top to bottom. The first pattern that matches wins. So, you should put your most specific cases before more general ones. The compiler is smart and will often warn you if one case is completely overshadowed by another.

What about performance? You might worry about all this new syntax. In practice, the performance is the same as the old, manual way of checking and casting. The compiler translates the pattern matching code into efficient bytecode. You get cleaner source code without a runtime cost.

Getting started is simple. These features are finalized and ready. You need JDK 21 or later for the full set of features discussed here. Earlier versions like JDK 17 and 19 introduced parts of it. Update your project, check your build configuration, and start by refactoring one long if-else chain. You'll quickly see the benefits.

The shift is mental as much as it is syntactic. You start thinking about the shapes of your data. You design your class hierarchies, particularly with records and sealed types, knowing how easily they will be used in pattern matches. Code becomes less about instructions and more about declarations. You say what you want to do with the data, not how to painfully pull it apart. It makes Java feel more expressive and direct, reducing the boilerplate that used to get in the way of the actual logic. It’s a tool that, once you use it, quickly becomes indispensable for writing clear, robust, and maintainable code.

📘 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)