Effective Pattern Matching 2026 Edition

Since Java 14, the Java switch and instanceof statements have been enhanced, in multiple phases, to support pattern matching and a “data-oriented” programming style. In this article, I explore when this programming style is beneficial, and why. I describe the syntax of pattern matching, as it has evolved, and show how it will be further enhanced in upcoming versions of Java. I warn of corner cases of the switch syntax where modern usage clashes with legacy behavior. I conclude with a set of rules when to use pattern matching and “data orientation” now, how to prepare for future enhancements, and when to stay away.

Data-Oriented Programming

Often times, when you model a business process, your data model is so simple that object-oriented programming seems overkill. Consider a fee transaction in a bank. There is a reason for the fee, and the fee amount, modeled here for simplicity as a double. (In real life, use a BigDecimal!)

record Fee(String reason, double amount) {}

A record is perfect here. We don’t need setters, and we don’t need encapsulation. The data representation will never change.

Ok, maybe we want some object-orientation. Let’s say we have two more transaction types, deposits and withdrawals. Then we want all three of them to implement a common interface:

sealed interface BankTransaction permits Deposit, Withdrawal, Fee {
    double amount();
}

record Deposit(double amount) implements BankTransaction {}
record Withdrawal(double amount) implements BankTransaction {}
record Fee(String reason, double amount) {}

Note that the interface is sealed. Only the listed types can implement it.

.svg

Once you have this setup—a sealed hierarchy of record types—you can use pattern matching to process it:

balance += switch (bankTransaction) {
    case Deposit(var amount) -> amount;
    case Withdrawal(var amount) -> -amount;
    case Fee(var amount, _) -> -amount;
};

Before continuing, a disclaimer. I have never written a banking application. It may well be that the problem domain has subtleties that make this data model seem naïve. A shout-out to Chris Kiehl’s book. He shows you how you can find data orientation in real business scenarios.

Sometimes, sealed hierarchies of record types are called algebraic data types. The name comes from the fact that subtypes behave somewhat like sums, and records like products. The details are not all that illuminating, and I won’t dwell on them. The important insight is that this particular structure makes pattern matching work. Each case picks up an alternative, and each record is deconstructed into its components.

The Pattern Matching Switch

Have another look at the switch from the preceding section. Note that it is an expression—it yields a value.

Each case deconstructs a record, binding variables to component values. However, the wildcard _ doesn’t bind a component.

The cases are exhaustive—they cover all possibilities of the sealed hierarchy

Finally, a bit of jargon: the value inside switch (...) is called the selector.

The switch syntax is easier than a sequence of instanceof tests, and it is much easier than the traditional OO approach, of adding a method to each type of the inheritance hierarchy.

Let us look at some syntactic variations. In the following example, the guard forbid overdrafts:

case Withdrawal(var amount) when amount > balance
    -> throw new BankTransactionException(...);

The compiler can’t reason about exhaustiveness with guards. You need to add an unguarded case below.

case Withdrawal(var amount) -> -amount;

Let’s add a target account to a withdrawal:

record Withdrawal(double amount, Account target) implements BankTransaction {}

Now we can forbid withdrawal to the same account:

case Withdrawal(var amount, Account(var id))
    when id.equals(currentAccount.id()) -> throw ...

Note the nested Account record in the pattern.

So far, you have seen record patterns. Another pattern, called a type pattern, binds a variable to entire selector:

case Fee f -> f.reason();

Patterns also work in instanceof expressions:

if (selector instanceof Fee f) ...
if (selector instanceof Fee(var _, var reason)) ...

At some point in the future, there will be patterns that work just like record patterns, but they can deconstruct instances of classes that are not records. Patterns will also be allowed in constructs other than switch and instanceof. Here is an example:

for (Map.Entry(var key, var val) : map.entrySet()) { ... }
    // Not yet, but maybe in 2027?

Why Switch?

The switch syntax for pattern matching seems intuitive enough. The familiar switch statement has a number of cases, just like in our modern situation, where we apply various patterns.

However, there are important differences between classic and modern switches that you should keep in mind, so that you can use the modern feature effectively.

Java 1.0 adopted the classic switch statement from the C programming language. It is intended to effectively produce a jump table. Depending on the selector value, the code jumps to one of the cases. This example is from Kernighan and Ritchie’s C programming book:

switch (c) {
   case '0': case '1': case '2': case '3': case '4':
   case '5': case '6': case '7': case '8': case '9':
      ndigit[c-'0']++;
      break;
   case ' ':
   case '\n':
   case '\t':
      nwhite++;
      break;
   default:
      nother++;
      break;
}

Since there are only a handful of cases in a narrow range, one can put the starting address of the code in each case into an array, making the jumps very efficient.

That is why the break statements are necessary. They cause jumps to the end of the statement. Without them, execution “falls through” to the next case, which is rarely desirable.

.svg

Note that here, the cases are unordered. If you reorder them, the jump addresses are just rearranged in the table. In contrast, pattern matching is strictly sequential, looking at all cases from the top to the bottom until one of them matches.

The classic switch is a statement, whereas pattern matching is usually used as an expression. To bridge this gap, Java 14 introduced two other kinds of switch, for a total of four:

ExpressionStatement
No Fallthrough
int numLetters = switch (seasonName) {
    case "Spring" -> {
        IO.println("spring time!");
        yield 6;
    }
    case "Summer", "Winter" -> 6;
    case "Fall" -> 4;
    default -> -1;
};
switch (seasonName) {
    case "Spring" -> {
        IO.println("spring time!");
        numLetters = 6;
    }
    case "Summer", "Winter" ->
        numLetters = 6;
    case "Fall" ->
        numLetters = 4;
    default ->
        numLetters = -1;
}
Fallthrough
int numLetters = switch (seasonName) {
    case "Spring":
        IO.println("spring time!");
    case "Summer", "Winter":
        yield 6;
    case "Fall":
        yield 4;
    default:
        yield -1;
};
switch (seasonName) {
    case "Spring":
        IO.println("spring time!");
    case "Summer", "Winter":
        numLetters = 6;
        break;
    case "Fall":
        numLetters = 4;
        break;
    default:
        numLetters = -1;
}

I suggest that you stay away from the “fall through” form and favor expressions over statements.

The Java 1 switch only works with types int, short, char, and byte. Java 5 introduced String and enum selectors. But what if they are null? Then an exception is thrown.

Nowadays, you can add a case null.

You have to be a bit careful about null in nested patterns. Consider again our Fee with a nested Account record:

case Withdrawal(var amount, Account(var id)) -> ...

If the selector is a new Withdrawal(100, null), then a MatchError s thrown because the id() method cannot be applied to a null account.

In practice, try to avoid using null in nested records. It makes pattern matching quite tedious. You don’t want to add checks such as

case Withdrawal(_, Account a) when a == null -> ...

Here Be Dragons

The rules for classic switch are more intricate than many programmers realize, and the pattern matching form tries to respect them as much as possible, while also trying to steer users to modern usage patterns.

Here is an example. Does this code compile?

enum Color { RED, YELLOW, GREEN };
boolean go(Color c) {
    switch (c) {
        case RED: return false;
        case YELLOW, GREEN: return true;
    }
}

No—the compiler complains about a “missing return statement.” It has been so since Java 5, since someone might add another enum constant, without recompiling this code.

Does this compile?

Boolean go(Color c) {
    switch (c) {
        case RED: return false;
        case YELLOW, GREEN: return true;
        case null: return null;
    }
}

Yup. case null makes it “enhanced.” And the rules are subtly different for enhanced switches.

Such finer points can be bewildering even for experts, and you can find quite a few “puzzlers” in blogs and presentations. These puzzlers can be fun for mastering those finer points, if indeed you are interested in such mastery.

But don’t let them scare you from using pattern matching! You won’t run into grief as long as you stay away from some pathological situations.

Record and type patterns are fine, as long as you stay away from nested null values. That applies to both switch and instanceof expressions.

You can safely use enumerations when they are a part of a sealed hierarchy. For example, one might define a part of a JSON hierarchy as follows:

public interface JSONObject {}
public interface JSONPrimitive extends JSONObject {}
public record JSONString(String value) extends JSONPrimitive {}
public enum JSONBoolean { FALSE, TRUE } implements JSONPrimitive {}
...

As always, an enum is appropriate when there are only a finite number of values.

Now you can write expressions such as

boolean truthiness = switch (jsonPrimitive) {
    case JSONString(String v) -> return v.equals("") || v.equals("false");
    case JSONBoolean.TRUE -> true;
    case JSONBoolean.FALSE -> false;
    ...
};

Here, the selector value belongs to a sealed hierarchy, and there is no possibility of conflict with classic rules. Things get murkier when the selector type is Object or a wrapper such as Integer.

Object obj = ...;
switch (obj) {
    case 0 ... // Error
    case JSONBoolean.TRUE ... // Ok
    ...
}

Integer n = ...;
switch (n) {
    case Integer i when i >= 0 ... // Doesn't dominate case 0
    default ... // Doesn't dominate case 0
    case 0 ... // Ok
    case null ... // Error: dominated by default (even though not covered)
    ...
}

Fortunately, you are unlikely to run into examples of this kind in the wild.

Java 26 introduces primitive type patterns that check whether a value can be losslessly represented.

int n = ...;
if (n instanceof byte b) // b set to n if -128 ≤ n ≤ 127

This can lead to an unhappy pitfall in a nested pattern:

sealed interface Shape {}
record Rect(double x, double y) implements Shape {}
record Square(double x) implements Shape {}
record Circle(double x) implements Shape {}

public List<Shape> dareToBeSquare(List<Shape> shapes) {
    return shapes.stream()
        .map(s -> switch(s) {
            case Rect(int x, _) -> new Square(x);
            default -> s;
        })
        .toList();
}

Did you notice the case Rect(int x, _)?

This case only matches rectangles whose x-coordinate is a double that can be safely cast to an int. With 0.5 or 2233445566.0, the default case is used.

The remedy is to use var inside patterns: case Rect(var x, _).

Conclusion

Pattern matching works great with sealed hierarchies of records. You don’t have to plan the perfect inheritance hierarchy, but simply use a switch whenever you need to extract values.

The syntax is generally regular and easy to use. The consistent use of pattern in switch and instanceof is a plus.

However, there are conflicts between modern and classic switch usage that can give rise to “sharp edges”. These are a delight for puzzler authors, and mostly occur with very generic type tests and conversions among primitive types. Fortunately they don’t usually apply to the context of data-oriented programming.

Stay away from the troublesome cases and enjoy the convenience of pattern matching! For now, think about where “algebraic data types” lurk inside your domain models. Future versions of Java will bring more opportunities.

Interested in Learning More?
Cay Horstmann is a speaker at JCON!
This article dives into pattern matching and data-oriented programming in modern Java – and his JCON session takes a closer look at what really happens under the hood when working with streams. If you can’t attend live, the session video will be available after the conference – it’s worth checking out!

Total
0
Shares
Previous Post

More Than Just a Conference for Java Enthusiasts | JCON 2026

Next Post

curl | bash | hacked: the unseen dangers in your dev lifecycle

Related Posts