JAVA / RECORDS, SEALED TYPES AND ENUMS
Pattern matching with instanceof and switch
Use type patterns in instanceof and switch, deconstruct records in case labels, add when guards, and get compile-checked exhaustiveness.
What you will learn
- Replace instanceof plus cast with a type pattern and use the bound variable directly
- Predict where a pattern variable is in scope from the flow scoping rules
- Deconstruct records, including nested ones, straight inside a case label
- Use when guards and sealed selectors so javac checks order and completeness
Understanding Pattern matching with instanceof and switch
A type pattern such as o instanceof String s does three things at once: it tests the runtime type, and if the test succeeds it declares a new variable of that type holding the value. That variable exists only where the compiler can prove the pattern matched, which is called flow scoping. That is why o instanceof String s && s.isBlank() compiles, since the right operand is reached only when the left one was true, while swapping && for || does not compile, and why if (!(o instanceof String s)) return; leaves s usable for the whole rest of the method.
switch accepts the same patterns as case labels, so a case can say "any Rectangle" instead of "the constant 3". Labels are tested top to bottom and the first match wins, so javac rejects a label that could never be reached because an earlier one already covers it: put case Object o first and the file will not compile. A when clause attaches a boolean guard that is evaluated only after the pattern matched, which keeps the extra condition in the label rather than in an if inside the body where it would need its own else.
A record pattern goes one level deeper: case Rectangle(double w, double h) matches a Rectangle and calls its accessors to bind the components, and it nests, so Line(Point(var x1, var y1), Point p2) takes apart a small object graph in a single label. Because the components arrive through accessors and not through direct field reads, a record whose accessor normalises a value hands you the normalised value. Combine patterns with a sealed selector type and a switch expression needs no default at all: the compiler verifies that every permitted subtype is covered, so adding a subtype later becomes a compile error in every switch instead of a silently taken default branch.
extra
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
public class Patterns {
static double area(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Rectangle(double w, double h) -> w * h;
};
}
static String describe(Shape shape) {
return switch (shape) {
case Rectangle(double w, double h) when w == h -> "square with side " + w;
case Rectangle r -> "rectangle " + r.width() + " by " + r.height();
case Circle c -> "circle of radius " + c.radius();
};
}
static double totalArea(Object... items) {
double sum = 0;
for (Object item : items) {
if (item instanceof Shape s) {
sum += area(s);
}
}
return sum;
}
public static void main(String[] args) {
System.out.println(describe(new Rectangle(3, 3)));
System.out.println(describe(new Rectangle(3, 4)));
System.out.println(describe(new Circle(2)));
System.out.println(area(new Rectangle(3, 4)));
System.out.println(totalArea(new Rectangle(2, 3), "not a shape", new Rectangle(1, 1)));
}
}A pattern is a type test and a variable binding fused into one expression, and switch turns a list of patterns into a decision whose order and completeness the compiler can check.
Worked examples
instanceof inside equals
Shows how a negated pattern puts the binding in scope for the rest of the method and removes the cast entirely.
public class Points {
static final class Point {
private final int x;
private final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return 31 * x + y;
}
}
public static void main(String[] args) {
Point a = new Point(1, 2);
System.out.println(a.equals(new Point(1, 2)));
System.out.println(a.equals(new Point(1, 3)));
System.out.println(a.equals("1,2"));
System.out.println(a.equals(null));
}
}Example explained
Line 1if (!(o instanceof Point p)) return false; binds p for all code after the if, because that code is reachable only when the pattern matched.
Line 2No (Point) o cast appears anywhere, so there is no second place where the type could be written wrongly.
Line 3a.equals("1,2") returns false instead of throwing ClassCastException: the pattern just fails and p is never created.
Line 4a.equals(null) also returns false, because a type pattern never matches null.
Nested record patterns with a guard
Deconstructs two levels of records in one case label and refines the match with when.
record Point(int x, int y) {}
record Line(Point start, Point end) {}
public class Nested {
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 + " from y=" + y1 + " to y=" + y2;
case Line(Point p, Point q) -> "line " + p + " -> " + q;
case Point p -> "single point " + p;
default -> "not a geometry object";
};
}
public static void main(String[] args) {
System.out.println(describe(new Line(new Point(2, 0), new Point(2, 5))));
System.out.println(describe(new Line(new Point(0, 0), new Point(3, 4))));
System.out.println(describe(new Point(7, 8)));
System.out.println(describe(42));
}
}Example explained
Line 1Line(Point(var x1, var y1), Point(var x2, var y2)) tests three objects and binds four ints; var infers int from the component declarations.
Line 2when x1 == x2 runs only after the whole nested pattern matched, so the bindings it reads already exist.
Line 3case Line(Point p, Point q) binds the components as whole records, which is why the second line prints their record toString.
Line 4describe(42) reaches default because an Integer is neither a Line nor a Point.
null in a pattern switch
Demonstrates that default does not cover null and that only a case null label does.
public class NullSwitch {
static String kind(Object o) {
return switch (o) {
case null -> "nothing";
case String s -> "string of length " + s.length();
case Integer i -> "number " + i;
default -> "something else";
};
}
static String unsafe(Object o) {
try {
return switch (o) {
case String s -> "string";
default -> "other";
};
} catch (NullPointerException e) {
return "NullPointerException";
}
}
public static void main(String[] args) {
System.out.println(kind(null));
System.out.println(kind("abc"));
System.out.println(kind(4));
System.out.println(unsafe(null));
}
}Example explained
Line 1case null -> "nothing" is the only label that a null selector can match.
Line 2In unsafe the selector is compared against null before any label is tried, so the exception is thrown before default is ever considered.
Line 3case Integer i matches the Integer that autoboxing creates for the call kind(4), and i is already typed as Integer in the body.
Important notes
Type patterns in instanceof are final since Java 16, while patterns in switch and record patterns are final since Java 21 and need --enable-preview on 17 to 20.
The type written for a record component must be the component type or a supertype of it, so Rectangle(int w, int h) over double components is a compile error; var avoids the question by inferring it.
Common mistakes
Writing the broad label first, for example case Shape s before case Circle c: this does not fall through to the specific case, javac rejects the file with "this case label is dominated by a preceding case label".
Expecting the binding after ||, as in if (o instanceof String s || s.length() > 2): it fails to compile with "cannot find symbol: s", because the binding exists only where the pattern definitely matched.
Assuming default handles null: a pattern switch throws NullPointerException on a null selector before default is considered, so you need case null or a null check before the switch.
Try it yourself
Change, predict, then run
Define sealed interface Expr with records Num(int value), Add(Expr left, Expr right) and Neg(Expr inner), then write int eval(Expr e) as one pattern switch with no default that includes the nested label case Add(Num(int a), Num(int b)) -> a + b plus a general Add case. Then delete the Neg case and read the error the compiler gives you.
Open the Java workspaceCheck your understanding
A switch expression over an Object selector has the labels case Integer i when i > 100, then case Integer i, then case Object obj, and no default. What happens when it is called with null?
- It returns the case Object obj result, since a null reference is still an Object
- It fails to compile, because a pattern switch always requires an explicit default label
- It throws NullPointerException, because no label can match null
- It returns the case Integer i result, since Integer is the first reference type tested
Show answer
A pattern switch compares the selector with null first, and without a case null label that comparison throws NullPointerException, so the labels are never tried. Option 0 is tempting because case Object obj covers every value the switch can actually deliver and makes the switch exhaustive, but a type pattern requires a non-null instance, so null never reaches it.