instanceof patterns, switch expressions, and record patterns — exhaustive matching in Java.
Published September 21, 2026
Code that inspects an object's type and then pulls data out of it used to be repetitive and error-prone in Java: an instanceof check, then a cast, then getter calls, repeated for every type. Pattern matching, delivered in steps from Java 16 to Java 21, lets you test a value's shape and extract its parts in one step. The compiler checks both the types and, with sealed hierarchies, that every case is covered.
instanceof with a binding variable (Java 16)// Before: test, then cast, and the type is written twice
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.length());
}
// Now: the test introduces a typed variable
if (obj instanceof String s) {
System.out.println(s.length());
}
// The binding is usable in the rest of the same condition
if (obj instanceof String s && !s.isBlank()) { ... }
The variable s exists only where the compiler can prove the test succeeded (flow scoping). That includes the code after a negated test that exits early:
if (!(obj instanceof Order order)) {
throw new IllegalArgumentException("expected an Order");
}
order.submit(); // in scope: we only get here if the pattern matched
A nice everyday use is equals:
@Override public boolean equals(Object o) {
return o instanceof Money m && amount.equals(m.amount) && currency.equals(m.currency);
}
switch (Java 21)switch can now branch on types, not just constants, and bind a variable in each case:
String describe(Object value) {
return switch (value) {
case null -> "nothing"; // explicit null handling (otherwise NPE)
case Integer i -> "int " + i;
case String s -> "text of length " + s.length();
case List<?> l -> "list with " + l.size() + " items";
default -> "something else: " + value.getClass().getSimpleName();
};
}
whenAdd a condition to a case. It only matches when both the type and the guard hold:
String classify(Object o) {
return switch (o) {
case Integer i when i < 0 -> "negative";
case Integer i when i == 0 -> "zero";
case Integer i -> "positive";
case String s when s.isBlank() -> "blank text";
case String s -> "text";
default -> "other";
};
}
Cases are tried top to bottom, so a more general case placed before a more specific one would make the specific one unreachable. The compiler rejects that as a dominance error:
case CharSequence cs -> ...
case String s -> ... // ❌ compile error: already covered by CharSequence above
Put specific cases, and guarded cases, before general ones.
A record pattern matches a record and pulls its components into variables. Patterns can be nested:
record Point(int x, int y) {}
record Line(Point start, Point end) {}
double length(Object shape) {
if (shape instanceof Line(Point(var x1, var y1), Point(var x2, var y2))) {
return Math.hypot(x2 - x1, y2 - y1);
}
return 0;
}
var lets the compiler infer each component's type. Nested deconstruction replaces chains like line.start().x() and makes the code's intent (the parts it actually uses) visible.
Combine a sealed hierarchy (a closed set of subtypes, see Sealed Classes) with record patterns, and a switch needs no default. The compiler checks that every subtype is handled:
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr inner) implements Expr {}
int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
case Neg(Expr inner) -> -eval(inner);
};
}
// eval(new Add(new Num(2), new Mul(new Num(3), new Num(4)))) → 14
Add a new record Div(...) implements Expr and every switch over Expr that doesn't handle Div stops compiling. The compiler finds all the places that must change, instead of a default branch silently mishandling the new case at runtime.
Patterns can also look into the data to simplify:
Expr simplify(Expr e) {
return switch (e) {
case Mul(Num(int one), Expr x) when one == 1 -> simplify(x); // 1 * x → x
case Add(Expr x, Num(int zero)) when zero == 0 -> simplify(x); // x + 0 → x
default -> e;
};
}
sealed interface FetchResult<T> {
record Found<T>(T value) implements FetchResult<T> {}
record NotFound<T>(String id) implements FetchResult<T> {}
record Failed<T>(Exception cause) implements FetchResult<T> {}
}
ResponseEntity<?> toResponse(FetchResult<UserDto> result) {
return switch (result) {
case FetchResult.Found<UserDto>(var user) -> ResponseEntity.ok(user);
case FetchResult.NotFound<UserDto>(var id) -> ResponseEntity.notFound().build();
case FetchResult.Failed<UserDto>(var cause) -> ResponseEntity.internalServerError().build();
};
}
Every outcome is explicit, and none can be forgotten.
if (x instanceof A) … else if (x instanceof B) chains.shape.area()) is still the better design. Pattern matching shines when operations are added more often than types, or when the types are plain data you don't control.Q: What happens if the value is null in a pattern switch?
A: Without a case null, the switch throws NullPointerException, as switches always have. Add case null -> (it can be combined as case null, default ->) to handle it explicitly.
Q: Why doesn't a switch over a sealed interface need default?
A: The compiler knows the complete list of permitted subtypes, so it can verify that the cases cover all of them. Leaving out default is also what gives you a compile error when a new subtype is added later.
Q: What is a dominance error?
A: A case that can never match because an earlier case already matches everything it would, such as case Object o before case String s, or an unguarded case Integer i before a guarded case Integer i when …. The compiler rejects it, so you order cases from specific to general.
Q: How are when guards different from putting an if inside the case?
A: A failed guard means the case didn't match, so evaluation continues with the next cases. An if inside the case body has already committed to that case. Guards keep the selection logic in the case labels, where the compiler can reason about coverage.
Q: Does pattern matching work with generics?
A: Yes, with the usual erasure limits: you can match case List<?> l but not distinguish List<String> from List<Integer> at runtime. Record patterns on generic records infer type arguments where they can.