Lesson 8 of 71
Pattern Matching
A pattern tests whether a value has a certain shape and, if it does, extracts parts of it into variables. Java added patterns in steps from Java 16 to Java 22. This lesson builds on Records and Sealed Types and ends with a data-oriented design that uses all of them.
| Feature | Final in |
|---|---|
instanceof type patterns |
Java 16 |
Patterns in switch, guards, case null |
Java 21 |
| Record patterns, nested patterns | Java 21 |
Unnamed patterns _ |
Java 22 |
| Primitive types in patterns | preview in Java 23, 24, and 25 |
Example 1: instanceof patterns
A type pattern Type name matches when the value is an instance of Type and binds it to name. It removes the cast that followed every instanceof check before Java 16.
public class InstanceofPattern {
static String describe(Object o) {
if (o instanceof String s && !s.isEmpty()) {
return "string of length " + s.length();
}
if (!(o instanceof Integer i)) {
return "something else";
}
return "integer doubled " + (i * 2); // i is in scope here
}
public static void main(String[] args) {
System.out.println(describe("hello"));
System.out.println(describe(21));
System.out.println(describe(""));
System.out.println(describe(null));
}
}string of length 5
integer doubled 42
something else
something elseThe binding s is in scope only where the match is certain: on the right of && and inside the if body. In the negated form, i is in scope after the if because the method returns when the match fails. This is called flow scoping. null instanceof X is always false, so patterns never bind null.
Example 2: type patterns in switch
Since Java 21, case labels can hold type patterns, so one switch can branch on the runtime type of a value.
public class SwitchTypePatterns {
static String format(Object value) {
return switch (value) {
case Integer i -> "int " + i;
case Long l -> "long " + l + "L";
case String s -> "text \"" + s + "\"";
case int[] arr -> "int array of " + arr.length;
default -> "other " + value.getClass().getSimpleName();
};
}
public static void main(String[] args) {
System.out.println(format(7));
System.out.println(format(7L));
System.out.println(format("seven"));
System.out.println(format(new int[3]));
System.out.println(format(7.0));
}
}int 7
long 7L
text "seven"
int array of 3
other DoubleThe selector type is Object, so the switch must have default to be exhaustive. Cases are tested from top to bottom. Pattern switch is implemented with an invokedynamic bootstrap (SwitchBootstraps.typeSwitch), so it does not compile into a chain of instanceof checks in the bytecode.
Example 3: guards with when, and dominance
A guard is a boolean condition after when that must also be true for the case to match. A case dominates a later case when every value the later case matches is already matched by the earlier one. The compiler rejects dominated cases because they can never run.
public class GuardsAndDominance {
record Order(String symbol, int quantity, double price) {}
static String route(Object o) {
return switch (o) {
case Order ord when ord.quantity() <= 0 -> "reject: bad quantity";
case Order ord when ord.quantity() * ord.price() > 1_000_000 -> "route: block desk";
case Order ord -> "route: algo " + ord.symbol();
case CharSequence cs -> "text command " + cs;
default -> "ignored";
};
}
public static void main(String[] args) {
System.out.println(route(new Order("AAPL", 0, 190.0)));
System.out.println(route(new Order("AAPL", 10_000, 190.0)));
System.out.println(route(new Order("AAPL", 10, 190.0)));
System.out.println(route("CANCEL ALL"));
}
}reject: bad quantity
route: block desk
route: algo AAPL
text command CANCEL ALLGuarded cases must come before the unguarded case of the same type. Writing case Order ord -> first would make the guarded cases unreachable and cause a compile error. The same rule applies across types: case CharSequence cs followed by case String s does not compile, because String implements CharSequence. A guard is not checked for dominance in the other direction; the compiler does not try to prove that one guard implies another.
Example 4: null in switch
A switch on a null value throws NullPointerException unless it has a case null label. Java 21 added case null, which can also be combined with default.
public class NullInSwitch {
static String classify(String code) {
return switch (code) {
case null -> "missing";
case "NEW", "PENDING" -> "open";
case "FILLED" -> "closed";
default -> "unknown " + code;
};
}
static String legacy(String code) {
return switch (code) {
case "NEW" -> "open";
default -> "other";
};
}
static String merged(Object o) {
return switch (o) {
case Integer i -> "int " + i;
case null, default -> "not an int";
};
}
public static void main(String[] args) {
System.out.println(classify(null));
System.out.println(classify("PENDING"));
System.out.println(merged(null));
try {
legacy(null);
} catch (NullPointerException e) {
System.out.println("NPE without case null");
}
}
}missing
open
not an int
NPE without case nulldefault alone does not match null. The behavior keeps old switches compatible: they threw NullPointerException before Java 21 and still do. case null, default is the only way to share one body between null and the default case.
Example 5: record patterns, nesting, and unnamed patterns
A record pattern Point(int x, int y) matches a record and binds its components in one step, by calling the accessors. Record patterns can nest. An unnamed pattern _ (final in Java 22) matches any component without binding it.
public class RecordPatterns {
record Point(int x, int y) {}
record Line(Point from, Point to) {}
record Labeled(String label, Object value) {}
static String describe(Object o) {
return switch (o) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) when x1 == x2 ->
"vertical line at x=" + x1 + " length " + Math.abs(y2 - y1);
case Line(Point from, _) -> "line starting at " + from;
case Labeled(String label, Point(_, int y)) -> label + " has y=" + y;
case Labeled(_, _) -> "labeled non-point";
default -> "unknown";
};
}
public static void main(String[] args) {
System.out.println(describe(new Line(new Point(2, 1), new Point(2, 6))));
System.out.println(describe(new Line(new Point(0, 0), new Point(3, 4))));
System.out.println(describe(new Labeled("target", new Point(5, 9))));
System.out.println(describe(new Labeled("name", "x")));
}
}vertical line at x=2 length 5
line starting at Point[x=0, y=0]
target has y=9
labeled non-pointvar inside a record pattern infers the component type. A nested pattern matches only when every sub-pattern matches, so Labeled(String label, Point(_, int y)) does not match a Labeled whose value is a String. A record pattern never matches null. Nested patterns differ: a nested type pattern that covers the whole component type (Point from for a Point component, or _) also matches a null component, so new Line(null, p) matches Line(Point from, _) with from bound to null. A nested record pattern such as Point(var x1, var y1) does not match a null component.
Example 6: exhaustiveness with sealed types
A switch over a sealed type is exhaustive when its cases cover every permitted subtype. Exhaustiveness also works through nested record patterns.
public class Exhaustive {
sealed interface Side permits Buy, Sell {}
record Buy() implements Side {}
record Sell() implements Side {}
sealed interface Quote permits Firm, Indicative {}
record Firm(Side side, long price) implements Quote {}
record Indicative(Side side) implements Quote {}
static String render(Quote q) {
return switch (q) {
case Firm(Buy _, long p) -> "firm bid " + p;
case Firm(Sell _, long p) -> "firm offer " + p;
case Indicative(Buy _) -> "indicative bid";
case Indicative(Sell _) -> "indicative offer";
};
}
public static void main(String[] args) {
System.out.println(render(new Firm(new Buy(), 100)));
System.out.println(render(new Firm(new Sell(), 101)));
System.out.println(render(new Indicative(new Sell())));
}
}firm bid 100
firm offer 101
indicative offerThere is no default. The compiler checks that Firm(Buy _, ...) and Firm(Sell _, ...) together cover every Firm, because Side permits only Buy and Sell. If a class compiled against an older version of the hierarchy meets a new subtype at run time, the exhaustive switch throws MatchException. That is the reason the compiler adds a hidden default that throws instead of falling through silently.
Example 7: primitive patterns (preview)
Primitive types in patterns let instanceof and switch test whether a primitive value fits a type without loss. The feature was a preview in Java 23 (JEP 455), Java 24 (JEP 488), and Java 25 (JEP 507). It is not final in Java 25, so the program needs --enable-preview.
// java --enable-preview --source 25 PrimitivePatterns.java
public class PrimitivePatterns {
static String fits(long value) {
if (value instanceof byte b) return value + " fits in byte " + b;
if (value instanceof int i) return value + " fits in int " + i;
return value + " needs long";
}
static String status(int code) {
return switch (code) {
case 200 -> "ok";
case int c when c >= 500 -> "server error " + c;
case int c -> "other " + c;
};
}
public static void main(String[] args) {
System.out.println(fits(100));
System.out.println(fits(40_000));
System.out.println(fits(5_000_000_000L));
System.out.println(status(200));
System.out.println(status(503));
System.out.println(status(404));
}
}100 fits in byte 100
40000 fits in int 40000
5000000000 needs long
ok
server error 503
other 404value instanceof byte b is true only when the conversion from long to byte loses no information. Without the preview, instanceof with a primitive type and a pattern over int in switch do not compile. The javac flag form is --release 25 --enable-preview.
Example 8: data-oriented programming
Data-oriented programming separates data from the operations on it: data is modeled with immutable records and sealed interfaces, and operations are functions that switch over the shapes. This is the opposite trade-off to the visitor pattern: adding an operation is a new function, while adding a data shape makes the compiler list every function to update.
import java.util.List;
public class OrderEvents {
sealed interface Event permits Placed, Filled, Cancelled {}
record Placed(String id, String symbol, int qty, long priceCents) implements Event {}
record Filled(String id, int qty) implements Event {}
record Cancelled(String id, String reason) implements Event {}
record Position(int openQty, int filledQty, int cancelled) {
Position apply(Event e) {
return switch (e) {
case Placed(_, _, int qty, _) -> new Position(openQty + qty, filledQty, cancelled);
case Filled(_, int qty) -> new Position(openQty - qty, filledQty + qty, cancelled);
case Cancelled(_, _) -> new Position(openQty, filledQty, cancelled + 1);
};
}
}
static String audit(Event e) {
return switch (e) {
case Placed p when p.qty() * p.priceCents() > 10_000_000 -> "LARGE " + p.id();
case Placed p -> "placed " + p.id();
case Filled(String id, int qty) -> "filled " + id + " x" + qty;
case Cancelled(String id, String reason) -> "cancelled " + id + " (" + reason + ")";
};
}
public static void main(String[] args) {
List<Event> log = List.of(
new Placed("o1", "AAPL", 100, 19_000),
new Placed("o2", "MSFT", 1_000, 41_000),
new Filled("o1", 60),
new Cancelled("o2", "user"));
var pos = new Position(0, 0, 0);
for (Event e : log) {
System.out.println(audit(e));
pos = pos.apply(e);
}
System.out.println(pos);
}
}placed o1
LARGE o2
filled o1 x60
cancelled o2 (user)
Position[openQty=1040, filledQty=60, cancelled=1]Both functions are exhaustive. o1 has notional 1,900,000 cents and o2 has 41,000,000 cents, so only o2 passes the guard. The Position record is rebuilt on each event instead of mutated, which lets tests check the state after each step.
Interview points
instanceofpattern bindings use flow scoping; they exist only where the match is certain.switchwithoutcase nullthrowsNullPointerExceptiononnull, even withdefault.- Dominance: a more general or unguarded case before a more specific one is a compile error.
- Exhaustive
switchover a sealed hierarchy needs nodefault; a new subtype breaks compilation. At run time a missed subtype from separate compilation throwsMatchException. - Record patterns call accessors and never match
null; nested patterns all must match, and a nested type pattern covering the component type also matches anullcomponent. _(Java 22) matches without binding; primitive patterns are preview through Java 25.- Data-oriented programming makes adding operations cheap and adding shapes compiler-checked, the inverse of the visitor pattern.
Summary
instanceofpatterns are final in Java 16; patternswitchand record patterns in Java 21; unnamed patterns in Java 22.- Guards use
when; guarded cases go before unguarded cases of the same type. case nullandcase null, defaulthandlenullexplicitly.- Sealed types plus records give compiler-checked exhaustive switches.
- Primitive patterns are preview in Java 23 to 25 and need
--enable-preview.
