Sealed Classes & Pattern Matching

Java · Lesson 20 of 32 6 min read Updated

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.
Java
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));
        }
    }
}
Output
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 implements Payment without being listed in permits, and you get error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause).
  • List Wallet in permits but forget it in the switch, and you get error: 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:

Java
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 {}
Output
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.

Java
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);
        }
    }
}
Output
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:

Java
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));
        }
    }
}
Output
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, sealed or non-sealed on a permitted class. Every permitted subclass must choose one (records and enums are implicitly final).
  • Adding default to 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 var if 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, C restricts which classes may extend a class or implement an interface.
  • Permitted subclasses must be final, sealed or non-sealed; records are implicitly final.
  • 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 take when guards; 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
Java
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));
        }
    }
}
Output
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?

2. Why can a switch over a sealed interface skip default?

3. What does case Rect(double w, double h) -> do?

4. Which modifier lets any class extend a permitted subclass again?

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