Sealed Classes & Pattern Matching
In Abstract Classes & Interfaces you saw that anyone can implement an interface. Usually that is what you want. But sometimes you know the complete list of possibilities: a payment is UPI, card or net banking, and nothing else; an order is placed, shipped, delivered or cancelled. Sealed classes and interfaces (Java 17) let you write that list into the code.
Once the compiler knows every possible subtype, it can check that your switch handles all of them, with no default needed. Combined with records and record patterns (Java 21), this gives Java a clean, safe way to model data, often called data-oriented programming. This lesson brings together records, interfaces and pattern matching from earlier lessons.
Declaring a sealed type
Add sealed to a class or interface and list the allowed subtypes after permits. Each permitted subtype must declare how it continues the hierarchy:
final: no further subclasses (records and enums are automatically final),sealed: its own restricted list of subclasses,non-sealed: open again, anyone may extend it.
public class Payments {
sealed interface Payment permits Upi, Card, NetBanking {}
record Upi(String vpa) implements Payment {}
record Card(String last4, boolean international) implements Payment {}
record NetBanking(String bank) implements Payment {}
static double fee(Payment p, double amount) {
return switch (p) {
case Upi u -> 0;
case Card c -> c.international() ? amount * 0.035 : amount * 0.015;
case NetBanking n -> 10;
};
}
public static void main(String[] args) {
Payment[] payments = {
new Upi("meera@okbank"),
new Card("4821", false),
new Card("7730", true),
new NetBanking("Punjab National Bank")
};
for (Payment p : payments) {
System.out.printf("%-48s fee Rs %.2f%n", p, fee(p, 2000));
}
}
}Upi[vpa=meera@okbank] fee Rs 0.00 Card[last4=4821, international=false] fee Rs 30.00 Card[last4=7730, international=true] fee Rs 70.00 NetBanking[bank=Punjab National Bank] fee Rs 10.00
(The fees are made up for the example.) The switch in fee has no default, yet it compiles, because the compiler knows Payment has exactly three subtypes and all three are covered. Now watch what the compiler does for you:
- Add a fourth record,
Wallet, that implementsPaymentwithout being listed inpermits, and you geterror: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause). - List
Walletinpermitsbut forget it in the switch, and you geterror: the switch expression does not cover all possible input values.
Every place that must handle the new payment type is found at compile time, before your users find the bug. With an open interface and a default branch, the new type would silently fall into default.
If all permitted subtypes are in the same file, you may leave out the permits clause; the compiler infers it. Writing it out is still clearer.
Sealed classes and non-sealed
The same rules work with abstract classes. A non-sealed subclass opens one branch of the hierarchy again:
public class Hier {
public static void main(String[] args) {
Vehicle[] all = {new Car(), new Truck(), new Electric()};
for (Vehicle v : all) System.out.println(v.getClass().getSimpleName());
}
}
abstract sealed class Vehicle permits Car, Truck, Bus {}
final class Car extends Vehicle {}
final class Truck extends Vehicle {}
non-sealed class Bus extends Vehicle {}
class Electric extends Bus {}Car Truck Electric
Car and Truck are closed, but any class may extend Bus, such as Electric. A switch over Vehicle must still handle Car, Truck and Bus, and a case Bus b also matches every subclass of Bus.
Record patterns
A record pattern matches a record and pulls out its components in one step. Instead of case Circle c -> Math.PI * c.radius() * c.radius(), you write case Circle(double r) and use r directly. This is called destructuring. You can use var for the component types, and add a when guard. Record patterns also work with instanceof.
public class Shapes {
sealed interface Shape permits Circle, Rect, Triangle {}
record Circle(double radius) implements Shape {}
record Rect(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle(double r) -> Math.PI * r * r;
case Rect(double w, double h) when w == h -> w * w; // a square
case Rect(double w, double h) -> w * h;
case Triangle(var b, var h) -> 0.5 * b * h;
};
}
public static void main(String[] args) {
Shape[] shapes = {new Circle(2), new Rect(4, 4), new Rect(3, 5), new Triangle(6, 3)};
for (Shape s : shapes) {
System.out.printf("%s -> %.2f%n", s, area(s));
}
Object o = new Rect(10, 2);
if (o instanceof Rect(double w, double h) && w > h) {
System.out.println("A wide rectangle: " + w + " x " + h);
}
}
}Circle[radius=2.0] -> 12.57 Rect[width=4.0, height=4.0] -> 16.00 Rect[width=3.0, height=5.0] -> 15.00 Triangle[base=6.0, height=3.0] -> 9.00 A wide rectangle: 10.0 x 2.0
The guarded Rect case comes before the plain one, just as in switch Statements & Expressions. The switch is exhaustive because the unguarded Rect(double w, double h) catches every rectangle the guarded case did not.
Nested patterns: a real example
Patterns can be nested, matching records inside records. Here an online order moves through four states, and a delivered order carries the customer and address:
public class Nested {
record Address(String city, String pin) {}
record Customer(String name, Address address) {}
sealed interface Status permits Placed, Shipped, Delivered, Cancelled {}
record Placed() implements Status {}
record Shipped(String courier) implements Status {}
record Delivered(Customer to) implements Status {}
record Cancelled(String reason) implements Status {}
static String message(Status s) {
return switch (s) {
case Placed p -> "Order placed, packing soon";
case Shipped(String courier) -> "On the way with " + courier;
case Delivered(Customer(String name, Address(String city, String pin)))
when city.equals("Kochi") -> "Delivered to " + name + " in our home city!";
case Delivered(Customer(String name, Address(String city, String pin))) ->
"Delivered to " + name + ", " + city + " " + pin;
case Cancelled(String reason) -> "Cancelled: " + reason;
};
}
public static void main(String[] args) {
Customer ananya = new Customer("Ananya", new Address("Kochi", "682001"));
Customer aarav = new Customer("Aarav", new Address("Jaipur", "302001"));
Status[] updates = {
new Placed(),
new Shipped("India Post"),
new Delivered(ananya),
new Delivered(aarav),
new Cancelled("payment failed")
};
for (Status s : updates) {
System.out.println(message(s));
}
}
}Order placed, packing soon On the way with India Post Delivered to Ananya in our home city! Delivered to Aarav, Jaipur 302001 Cancelled: payment failed
Compare this with the alternative: an if/else chain with instanceof checks, casts and getter calls three levels deep. The sealed interface documents every state in one place, the records hold the data, and the switch handles each state with the compiler checking that none is forgotten.
When to use sealed types
- Use them when the set of variants is fixed and known: states of an order, kinds of payment, shapes in a drawing app, results that are either a success or an error.
- Prefer ordinary interfaces when other people should be able to add new implementations, such as plug-ins.
- Pair sealed interfaces with records for data, and use exhaustive switches instead of
default, so the compiler tells you every place to update when a variant is added.
Common mistakes
- Forgetting
final,sealedornon-sealedon a permitted class. Every permitted subclass must choose one (records and enums are implicitly final). - Adding
defaultto a switch over a sealed type. It compiles, but it hides new subtypes from the exhaustiveness check. Leave it out. - Writing the wrong component types in a record pattern. They must match the record's components; use
varif unsure. - Putting a general case before a guarded one. The guarded case becomes unreachable, which is a compile error.
Key points
sealed ... permits A, B, Crestricts which classes may extend a class or implement an interface.- Permitted subclasses must be
final,sealedornon-sealed; records are implicitlyfinal. - A switch over a sealed type that covers every subtype needs no
default, and the compiler flags missing cases. - Record patterns such as
case Rect(double w, double h)match and destructure in one step (Java 21). - Patterns can be nested, use
var, and takewhenguards; Java 22 adds_for unused parts.
Exercise
Model the result of a UPI payment as a sealed interface Result with three records: Success(String txnId, int amount), Pending(int secondsWaited) and Failed(String reason). Write a method String explain(Result r) using a switch with record patterns: for Success, show the amount and transaction id; for Pending, say "please wait" if under 60 seconds, otherwise "contact your bank" (use a guard); for Failed, show the reason. Test with four results.
Show solution
public class Upi {
sealed interface Result permits Success, Pending, Failed {}
record Success(String txnId, int amount) implements Result {}
record Pending(int secondsWaited) implements Result {}
record Failed(String reason) implements Result {}
static String explain(Result r) {
return switch (r) {
case Success(String id, int amount) -> "Paid Rs " + amount + " (txn " + id + ")";
case Pending(int s) when s < 60 -> "Pending for " + s + "s, please wait";
case Pending(int s) -> "Pending for " + s + "s, contact your bank";
case Failed(String reason) -> "Failed: " + reason;
};
}
public static void main(String[] args) {
Result[] results = {
new Success("UPI8812", 499),
new Pending(20),
new Pending(180),
new Failed("wrong UPI PIN")
};
for (Result r : results) {
System.out.println(explain(r));
}
}
}Paid Rs 499 (txn UPI8812) Pending for 20s, please wait Pending for 180s, contact your bank Failed: wrong UPI PIN
The switch has no default. If you add a fourth result type such as Refunded, the compiler points straight at this switch until you handle it.
Quick Quiz
1. What does sealed interface Payment permits Upi, Card {} mean?
permits lists exactly which types may implement or extend the sealed type.
2. Why can a switch over a sealed interface skip default?
Knowing the complete set of subtypes makes exhaustiveness checking possible.
3. What does case Rect(double w, double h) -> do?
A record pattern tests the type and destructures the components.
4. Which modifier lets any class extend a permitted subclass again?
non-sealed reopens that branch of the hierarchy.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
