Pattern matching lets you test a value against a shape and bind its parts in one step, without a cast on the next line. Sealed types tell the compiler exactly which implementations are possible. Put together, they give you a switch that the compiler proves is exhaustive - if you add a new implementation, the build breaks until you handle it.
What does an instanceof pattern give you?
The old form tested a type and then cast in a separate statement, which left a window where the value was still typed as Object. The pattern form does both at once.
if (value instanceof String text) {
return text.length();
}The variable text exists only inside the branch, and it is already a String - no cast, no redeclaration. The same variable name can be reused in a later if because the previous one went out of scope.
public class InstanceofPattern {
static String describe(Object value) {
if (value instanceof String text) {
return "text of length " + text.length();
}
if (value instanceof Integer number && number > 100) {
return "big number " + number;
}
if (value instanceof Integer number) {
return "small number " + number;
}
if (value instanceof int[] numbers) {
return "array of " + numbers.length;
}
return "something else";
}
public static void main(String[] args) {
System.out.println(describe("hello"));
System.out.println(describe(500));
System.out.println(describe(7));
System.out.println(describe(new int[] { 1, 2, 3 }));
System.out.println(describe(3.5));
}
}text of length 5
big number 500
small number 7
array of 3
something elseThe third line of that output shows the pattern variable in a condition combined with a further test. Because number is in scope only where the pattern matched, the second if can reuse the name safely. The fourth line shows a pattern on an array type, which is a useful way to detect an array without an instanceof check on the array class.
What are sealed types?
A sealed interface or class lists the types that may implement it, in a permits clause. Nobody else can extend it, and the compiler knows the complete set.
public class SealedTypes {
sealed interface Payment permits Card, BankTransfer, Cash {
String summary();
}
record Card(String last4, double amount) implements Payment {
public String summary() {
return "card ending " + last4 + " for " + amount;
}
}
record BankTransfer(String iban, double amount) implements Payment {
public String summary() {
return "transfer of " + amount + " to " + iban;
}
}
record Cash(double amount) implements Payment {
public String summary() {
return "cash of " + amount;
}
}
public static void main(String[] args) {
Payment[] payments = {
new Card("4242", 19.99),
new Cash(30.0),
new BankTransfer("DE89370400440532013000", 250.0)
};
for (Payment payment : payments) {
System.out.println(payment.summary());
}
}
}card ending 4242 for 19.99
cash of 30.0
transfer of 250.0 to DE89370400440532013000The permits clause is the whole idea. Without it, an interface can be implemented by any class in any package, and no switch over it could ever be exhaustive. With it, the set of possible implementations is fixed at compile time, and the compiler can check that a switch covers all of them.
How does a switch become exhaustive?
When you switch over a sealed type, the compiler checks that every permitted implementation appears in a case. If one is missing, the build fails and names it.
public class SwitchOverSealed {
sealed interface Shape permits Circle, Square, Rectangle { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Rectangle r -> r.width() * r.height();
};
}
public static void main(String[] args) {
System.out.println("circle : " + area(new Circle(2)));
System.out.println("square : " + area(new Square(3)));
System.out.println("rectangle : " + area(new Rectangle(2, 5)));
}
}circle : 12.566370614359172
square : 9.0
rectangle : 10.0Look at the switch - there is no default branch, and the code still compiles. That is the payoff of sealing: the compiler proved that a Circle, a Square and a Rectangle are the only possibilities, so it knows the switch always produces a value. If you add a Triangle record to the permits clause, this switch stops compiling until you add a case for it. That is a guarantee you cannot get any other way in Java.
What are record patterns?
A record pattern goes one level deeper: it matches a record and deconstructs it into its components in a single step.
public class RecordPatterns {
record Point(int x, int y) { }
record Line(Point start, Point end) { }
static String describe(Line line) {
return switch (line) {
case Line(Point(var x1, var y1), Point(var x2, var y2)) -> {
int width = Math.abs(x2 - x1);
int height = Math.abs(y2 - y1);
yield width + "x" + height;
}
};
}
public static void main(String[] args) {
System.out.println(describe(new Line(new Point(1, 1), new Point(4, 3))));
System.out.println(describe(new Line(new Point(0, 0), new Point(2, 0))));
}
}3x2
2x0Compare that with the manual version. Instead of case Line l -> followed by l.start().x() and three more calls, the pattern pulls the four coordinates out directly. The var inside the pattern is not a wildcard - it binds each component to a local variable whose type the compiler knows. The nested structure is visible in the pattern itself, which is why record patterns read so well on deeply nested data.
Where should you use these features?
| Situation | Feature |
|---|---|
| Testing a type and using the value | instanceof pattern |
| A closed hierarchy of values | Sealed interface with permits |
| Deciding on the type of a value | switch over a sealed type |
| Unpacking a record | Record pattern |
| Data with no behaviour | Record |
The pattern of use is usually the same: model a small closed set of possibilities as sealed types with records, then write one switch that handles them. The result is code where every possible case is visible on one screen and the compiler keeps it honest as the model grows.
What are the limits of pattern matching?
Pattern matching is a compile-time feature with a few sharp edges. Knowing them saves you from fighting the compiler.
- An
ifpattern must be able to fail.if (x instanceof Object o)is rejected, because it would always match and the negation would be unreachable. - A
nullnever matches aninstanceofpattern, so a null check still has to come first. - A switch with a
defaultbranch is no longer checked for exhaustiveness, even over a sealed type. - Only one pattern per case can be an unguarded type pattern, and a guarded one has to come after it.
public class PatternLimits {
public static String describe(Object value) {
if (value == null) {
return "nothing at all";
}
if (value instanceof String text) {
return "text: " + text;
}
return "something else";
}
public static void main(String[] args) {
System.out.println(describe(null));
System.out.println(describe("hello"));
System.out.println(describe(42));
}
}nothing at all
text: hello
something elseThe null check in that program is not optional. A null reference matches no pattern at all, so without the first branch the instanceof test would simply be false and the method would fall through to "something else" - which quietly loses the difference between absent and wrong.
The default-branch point matters for the sealed type work. The moment you add a default to a switch over a sealed type, the compiler stops checking that every permitted implementation is covered, and you are back to defending against a new type at runtime.
The habits to keep
- Use an instanceof pattern instead of a test plus a cast.
- Seal a hierarchy when you know every possible implementation.
- Give a sealed type records as implementations - they are the natural payloads.
- Over a sealed type, skip the
defaultbranch and let the compiler check exhaustiveness. - Use record patterns to unpack nested data in one step.
- Keep the case bodies short, and move real logic into methods.
Try it yourself: model a payment system with a sealed interface permitting card, bank transfer and cash, each a record. Write a switch that returns the handling fee for each kind of payment, and a second switch that returns a receipt line. Then add a new payment type and let the compiler point you at the two switches you need to update.