Pattern matching in Java 21 is the culmination of several interconnected features: instanceof patterns (Java 16), sealed classes (Java 17), switch expressions (Java 14), and switch patterns (Java 21). Understanding them individually is useful. Understanding how they compose is where the real power is.
The Baseline: Pattern Matching for instanceof
Before Java 16, checking and casting looked like this:
Object shape = getShape();
if (shape instanceof Circle) {
Circle c = (Circle) shape; // Redundant cast
return c.area();
} else if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.area();
}
Java 16 introduced pattern variables in instanceof:
if (shape instanceof Circle c) {
return c.area(); // c is already cast
} else if (shape instanceof Rectangle r) {
return r.area();
}
The pattern variable c is bound only when the pattern matches. Its scope is limited to the block where the binding applies. The compiler understands the scope:
if (!(shape instanceof Circle c)) {
return 0; // c not in scope here
}
// c IS in scope here — the negative case returned
return c.area();
Switch Patterns: The Significant Upgrade
Pattern matching in switch (finalized Java 21) is where this becomes compelling:
// Old approach — instanceof chain
double area;
if (shape instanceof Circle c) {
area = Math.PI * c.radius() * c.radius();
} else if (shape instanceof Rectangle r) {
area = r.width() * r.height();
} else if (shape instanceof Triangle t) {
area = 0.5 * t.base() * t.height();
} else {
throw new IllegalArgumentException("Unknown shape: " + shape);
}
// Switch pattern
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
default -> throw new IllegalArgumentException("Unknown shape: " + shape);
};
Cleaner, more readable. But the default clause is still there. To eliminate it, we need sealed classes.
Sealed Classes Enable Exhaustive Switches
Sealed classes (Java 17) restrict which classes can implement an interface or extend a class:
public sealed interface Shape
permits Circle, Rectangle, Triangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}
With a sealed Shape, the compiler knows all possible subtypes. Switch becomes exhaustive:
// No default needed — compiler verifies all Shape subtypes are handled
double area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
case Triangle t -> 0.5 * t.base() * t.height();
};
The enforcement guarantee: if you add a new shape to the sealed hierarchy, every switch that handles all cases produces a compile error until the new case is handled. The compiler becomes a refactoring assistant.
// Add Polygon to the sealed interface
public sealed interface Shape
permits Circle, Rectangle, Triangle, Polygon {} // Added Polygon
// Compiler errors in all exhaustive switches:
// error: the switch expression does not cover all possible input values
double area = switch (shape) {
case Circle c -> ...;
case Rectangle r -> ...;
case Triangle t -> ...;
// Missing: Polygon
};
Guarded Patterns
Patterns can include guard conditions with when:
String classify = switch (payment) {
case Payment p when p.amount().isGreaterThan(Money.of(10000)) -> "high-value";
case Payment p when p.method() == CRYPTO -> "crypto";
case Payment p when p.status() == FLAGGED -> "flagged";
case Payment p -> "standard";
};
Guards are evaluated in order. The first matching case wins. This enables ordered pattern matching with conditions — something that required complex if-else chains before.
Be careful with ordering: more specific cases should come before less specific ones, or a general case might consume matches before a specific one is checked.
Record Patterns: Destructuring in Patterns
Record patterns (finalized Java 21) extend pattern matching to destructure record components directly in the pattern:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// Without record patterns
if (shape instanceof Line l) {
Point start = l.start();
int x = start.x();
int y = start.y();
System.out.println("Line starts at " + x + ", " + y);
}
// With record patterns — nested destructuring
if (shape instanceof Line(Point(int x, int y), _)) {
System.out.println("Line starts at " + x + ", " + y);
}
The _ (unnamed pattern, Java 21) matches any value without binding it.
In switch:
sealed interface Expr
permits Expr.Num, Expr.Add, Expr.Mul {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
int evaluate(Expr expr) {
return switch (expr) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> evaluate(l) + evaluate(r);
case Mul(Expr l, Expr r) -> evaluate(l) * evaluate(r);
};
}
This is recursive pattern matching on an expression tree — a pattern common in compilers, interpreters, and rule engines. In Java 8 this required visitor patterns, type hierarchies, and significant boilerplate. In Java 21, it’s eight readable lines.
Practical Domain Modeling Example
The power of these features together is most visible in domain modeling. Consider an order processing pipeline:
public sealed interface OrderEvent
permits OrderEvent.Received, OrderEvent.Validated,
OrderEvent.PaymentProcessed, OrderEvent.Fulfilled,
OrderEvent.Cancelled {
record Received(OrderId id, CustomerId customer,
List<OrderLine> items, Instant receivedAt)
implements OrderEvent {}
record Validated(OrderId id, Money total,
boolean inventoryReserved)
implements OrderEvent {}
record PaymentProcessed(OrderId id, String transactionId,
Money charged)
implements OrderEvent {}
record Fulfilled(OrderId id, String trackingNumber,
Instant shippedAt)
implements OrderEvent {}
record Cancelled(OrderId id, CancellationReason reason,
boolean refundIssued)
implements OrderEvent {}
}
// Processing events — exhaustive, readable, no casting
void processEvent(OrderEvent event) {
switch (event) {
case OrderEvent.Received(var id, var customer, var items, var at) ->
log.info("Order {} from {} with {} items received at {}",
id, customer, items.size(), at);
case OrderEvent.Validated(var id, var total, true) -> // Guard: inventoryReserved=true
log.info("Order {} for {} validated, inventory reserved", id, total);
case OrderEvent.Validated(var id, var total, false) ->
log.warn("Order {} for {} validated but inventory not reserved", id, total);
case OrderEvent.PaymentProcessed(var id, var txId, var amount) ->
metrics.recordPayment(amount);
case OrderEvent.Fulfilled(var id, var tracking, var shippedAt) ->
notificationService.notifyShipped(id, tracking);
case OrderEvent.Cancelled(var id, var reason, true) ->
log.info("Order {} cancelled: {}, refund issued", id, reason);
case OrderEvent.Cancelled(var id, var reason, false) ->
log.info("Order {} cancelled: {}, no refund", id, reason);
}
}
When a new event type is added to the sealed interface, this switch immediately fails to compile. The compiler enforces that every event type is handled.
Null Handling
Switch patterns can handle null explicitly:
String status = switch (response) {
case null -> "no response";
case HttpResponse r when r.statusCode() == 200 -> "ok";
case HttpResponse r when r.statusCode() >= 400 -> "error: " + r.statusCode();
case HttpResponse r -> "other: " + r.statusCode();
};
Without the explicit null case, a null value would throw NullPointerException in a switch — same as before Java 21.
The Bigger Picture
Pattern matching is not a convenience feature. Together with records and sealed classes, it enables a fundamentally different approach to domain modeling in Java — one where:
- Domain types are immutable by default (records)
- The type system expresses the complete set of valid states (sealed classes)
- Processing code is exhaustive and compiler-enforced (switch patterns)
- Destructuring eliminates accessor boilerplate (record patterns)
This is how functional languages — Haskell, OCaml, Rust, Scala — have handled discriminated unions for decades. Java 21 brings the same expressiveness to mainstream Java.
For teams still on Java 8 or 11 writing instanceof chains and casting, the upgrade path to these features is one of the strongest arguments for adopting Java 21.