Skip to main content
Java · lesson 34 of 66
Open the Java page →
Java intermediate Lesson 34 of 66

Also part of: Low-Level Design

Sealed Classes and Interfaces in Java

Learn sealed classes and interfaces (Java 17+) by running them: permits, final vs non-sealed subtypes, exhaustive switch, record patterns and guards.

By SkillByExample · Updated

A sealed class or interface lists exactly which types may extend it. That sits between an open class (anyone can extend it) and a final one (no one can), and it gives the compiler a complete list of subtypes to check switch statements against. Every example is a single file run with the JDK 21 source launcher (java File.java).

Declaring a sealed hierarchy

public class VehicleDemo {
    sealed interface Vehicle permits Car, Truck, Motorcycle {}

    record Car(int doors) implements Vehicle {}            // records are implicitly final
    static final class Truck implements Vehicle {
        double payloadTons() { return 12.5; }
    }
    static non-sealed class Motorcycle implements Vehicle {} // reopens this branch
    static class Scooter extends Motorcycle {}              // allowed: Motorcycle is non-sealed

    public static void main(String[] args) {
        Vehicle[] fleet = { new Car(4), new Truck(), new Scooter() };
        for (Vehicle v : fleet)
            System.out.println(v.getClass().getSimpleName() + " is a Vehicle");
        System.out.println(java.util.Arrays.toString(Vehicle.class.getPermittedSubclasses()));
        System.out.println(Vehicle.class.isSealed());
    }
}
$ java VehicleDemo.java
Car is a Vehicle
Truck is a Vehicle
Scooter is a Vehicle
[class VehicleDemo$Car, class VehicleDemo$Truck, class VehicleDemo$Motorcycle]
true

Each permitted subtype must state how the hierarchy continues: final (records are implicitly final), sealed with its own permits, or non-sealed, which lets anything extend that branch — so Scooter compiles. Reflection exposes the closed list through getPermittedSubclasses().

What the compiler rejects

public class NotPermitted {
    sealed interface Shape permits Circle, Square {}
    record Circle(double r) implements Shape {}
    record Square(double side) implements Shape {}
    record Hexagon(double side) implements Shape {}

    public static void main(String[] args) {
        System.out.println(new Hexagon(1));
    }
}
$ java NotPermitted.java
NotPermitted.java:5: error: class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause)
    record Hexagon(double side) implements Shape {}
    ^
1 error
error: compilation failed

Hexagon is not in the permits list, so the program never compiles — the source launcher stops at the error. That is the whole point of sealing: the set of shapes is closed and the compiler enforces it, not a convention in a comment.

Exhaustive switch with no default

public class PaymentDemo {
    sealed interface Payment permits CreditCard, BankTransfer, Crypto {}
    record CreditCard(String number, double amount) implements Payment {}
    record BankTransfer(String iban, double amount) implements Payment {}
    record Crypto(String currency, double amount) implements Payment {}

    static double fee(Payment p) {
        return switch (p) {
            case CreditCard cc   -> cc.amount() * 0.025;
            case BankTransfer bt -> bt.amount() * 0.005;
            case Crypto c        -> c.amount() * 0.01;
        };
    }

    static String describe(Payment p) {
        return switch (p) {
            case CreditCard(var number, var amount) -> "Card ending " + number.substring(number.length() - 4) + " for $" + amount;
            case BankTransfer(var iban, var amount) -> "Transfer from " + iban + " for $" + amount;
            case Crypto(var currency, var amount)   -> amount + " " + currency;
        };
    }

    public static void main(String[] args) {
        Payment[] payments = { new CreditCard("4111111111111234", 100), new BankTransfer("DE89 3704", 2000), new Crypto("BTC", 0.5) };
        for (Payment p : payments)
            System.out.printf("%-36s fee %.4f%n", describe(p), fee(p));
    }
}
$ java PaymentDemo.java
Card ending 1234 for $100.0          fee 2.5000
Transfer from DE89 3704 for $2000.0  fee 10.0000
0.5 BTC                              fee 0.0050

Neither switch has a default: the compiler knows Payment has exactly three implementations and checks that the cases cover them. describe uses record patterns (Java 21) to destructure each record in the case itself. The payoff comes when the hierarchy grows, as the next example shows.

Adding a subtype breaks every incomplete switch

public class MissingCase {
    sealed interface Payment permits CreditCard, BankTransfer, PayPal {}
    record CreditCard(double amount) implements Payment {}
    record BankTransfer(double amount) implements Payment {}
    record PayPal(String email, double amount) implements Payment {}

    static double fee(Payment p) {
        return switch (p) {
            case CreditCard cc   -> cc.amount() * 0.025;
            case BankTransfer bt -> bt.amount() * 0.005;
        };
    }

    public static void main(String[] args) {
        System.out.println(fee(new PayPal("a@b.com", 10)));
    }
}
$ java MissingCase.java
MissingCase.java:8: error: the switch expression does not cover all possible input values
        return switch (p) {
               ^
1 error
error: compilation failed

Adding PayPal to permits turned an old, forgotten switch into a compile error. With an open interface and a default branch, the same change would compile and quietly charge PayPal the default fee. This is why a default is best left out of switches over sealed types.

Modelling state with sealed records

public class OrderDemo {
    sealed interface Status permits Pending, Processing, Shipped, Cancelled {}
    record Pending() implements Status {}
    record Processing(String warehouse) implements Status {}
    record Shipped(String tracking) implements Status {}
    record Cancelled(String reason) implements Status {}

    static Status process(Status s, String warehouse) {
        if (s instanceof Pending) return new Processing(warehouse);
        throw new IllegalStateException("Cannot process order in " + s);
    }

    static String summary(Status s) {
        return switch (s) {
            case Pending p           -> "Waiting for a warehouse";
            case Processing(var wh)  -> "Processing at " + wh;
            case Shipped(var t)      -> "Tracking: " + t;
            case Cancelled(var why)  -> "Cancelled: " + why;
        };
    }

    public static void main(String[] args) {
        Status s = new Pending();
        System.out.println(summary(s));
        s = process(s, "WH-EAST-1");
        System.out.println(summary(s));
        try {
            process(s, "WH-WEST-2");
        } catch (IllegalStateException e) {
            System.out.println("Caught: " + e.getMessage());
        }
    }
}
$ java OrderDemo.java
Waiting for a warehouse
Processing at WH-EAST-1
Caught: Cannot process order in Processing[warehouse=WH-EAST-1]

Each state carries only the data that makes sense in that state — a tracking number exists only on Shipped. process allows just the Pending -> Processing transition, so the second call fails and the exception message includes the record’s generated toString.

Recursive data: an expression tree

public class ExprDemo {
    sealed interface Expr permits Num, Add, Mul, Neg {}
    record Num(double value) implements Expr {}
    record Add(Expr left, Expr right) implements Expr {}
    record Mul(Expr left, Expr right) implements Expr {}
    record Neg(Expr expr) implements Expr {}

    static double eval(Expr e) {
        return switch (e) {
            case Num(var v)        -> v;
            case Add(var l, var r) -> eval(l) + eval(r);
            case Mul(var l, var r) -> eval(l) * eval(r);
            case Neg(var inner)    -> -eval(inner);
        };
    }

    static String show(Expr e) {
        return switch (e) {
            case Num(var v)        -> String.valueOf(v);
            case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
            case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
            case Neg(var inner)    -> "-" + show(inner);
        };
    }

    public static void main(String[] args) {
        Expr e = new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2)));
        System.out.println(show(e) + " = " + eval(e));
    }
}
$ java ExprDemo.java
((3.0 + 4.0) * -2.0) = -14.0

eval and show are two independent operations over the same closed set of node types. Adding an operation means writing one more switch; adding a node type makes the compiler point at every switch that needs a new case.

Sealed types vs the Visitor pattern

Before sealed types, the textbook way to run several operations over a closed set of node types was the GoF Visitor pattern. Here is the same tree, written as three files, with an accept method on every node. Put these in their own directory, since Expr here is not the sealed Expr above.

// Expr.java
interface ExprVisitor<R> {
    R visitNum(Num n);
    R visitAdd(Add a);
    R visitMul(Mul m);
    R visitNeg(Neg n);
}

interface Expr {
    <R> R accept(ExprVisitor<R> v);
}

record Num(double value) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitNum(this); }
}
record Add(Expr left, Expr right) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitAdd(this); }
}
record Mul(Expr left, Expr right) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitMul(this); }
}
record Neg(Expr expr) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitNeg(this); }
}

Each operation is a class that implements the visitor interface, with one method per node type.

// Visitors.java
class Eval implements ExprVisitor<Double> {
    public Double visitNum(Num n) { return n.value(); }
    public Double visitAdd(Add a) { return a.left().accept(this) + a.right().accept(this); }
    public Double visitMul(Mul m) { return m.left().accept(this) * m.right().accept(this); }
    public Double visitNeg(Neg n) { return -n.expr().accept(this); }
}

class Show implements ExprVisitor<String> {
    public String visitNum(Num n) { return String.valueOf(n.value()); }
    public String visitAdd(Add a) { return "(" + a.left().accept(this) + " + " + a.right().accept(this) + ")"; }
    public String visitMul(Mul m) { return "(" + m.left().accept(this) + " * " + m.right().accept(this) + ")"; }
    public String visitNeg(Neg n) { return "-" + n.expr().accept(this); }
}
// Main.java
public class Main {
    public static void main(String[] args) {
        Expr e = new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2)));
        System.out.println(e.accept(new Show()) + " = " + e.accept(new Eval()));
    }
}
$ javac *.java && java Main
((3.0 + 4.0) * -2.0) = -14.0

The output matches ExprDemo, but it took two dispatches to get there. e.accept(v) picks the node’s own accept, and that method calls back the one visitX that matches its type. This “double dispatch” is how you choose code by runtime type without instanceof chains, which is all Java offered before pattern matching. Expr is not sealed here. Instead, the visitor interface’s fixed list of visitX methods is what makes every operation handle every node type.

Here are the same two operations as exhaustive switches over a sealed interface, in a separate directory with the same three-file layout. The bodies are eval and show from ExprDemo.

// Expr.java
sealed interface Expr permits Num, Add, Mul, Neg {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr) implements Expr {}
// Ops.java
class Ops {
    static double eval(Expr e) {
        return switch (e) {
            case Num(var v)        -> v;
            case Add(var l, var r) -> eval(l) + eval(r);
            case Mul(var l, var r) -> eval(l) * eval(r);
            case Neg(var inner)    -> -eval(inner);
        };
    }

    static String show(Expr e) {
        return switch (e) {
            case Num(var v)        -> String.valueOf(v);
            case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
            case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
            case Neg(var inner)    -> "-" + show(inner);
        };
    }
}
// Main.java
public class Main {
    public static void main(String[] args) {
        Expr e = new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2)));
        System.out.println(Ops.show(e) + " = " + Ops.eval(e));
    }
}
$ javac *.java && java Main
((3.0 + 4.0) * -2.0) = -14.0

Same answer, with no ExprVisitor, no accept and no double dispatch. The records are plain data that know nothing about the operations run on them. The sealed permits list does the job the visitor interface did: it tells the compiler the complete set of cases.

Adding a node type to each version

Both designs claim the compiler will catch a missing case. To test that, add a Div node to the sealed version’s Expr.java and leave Ops.java alone.

// Expr.java
sealed interface Expr permits Num, Add, Mul, Neg, Div {}
record Num(double value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
record Neg(Expr expr) implements Expr {}
record Div(Expr left, Expr right) implements Expr {}
$ javac *.java && java Main
Ops.java:4: error: the switch expression does not cover all possible input values
        return switch (e) {
               ^
Ops.java:13: error: the switch expression does not cover all possible input values
        return switch (e) {
               ^
2 errors

javac names both operations, eval at line 4 and show at line 13 of Ops.java, and nothing runs. The fix is one case per switch. Main now divides by 4 so that the new node is used.

// Ops.java
class Ops {
    static double eval(Expr e) {
        return switch (e) {
            case Num(var v)        -> v;
            case Add(var l, var r) -> eval(l) + eval(r);
            case Mul(var l, var r) -> eval(l) * eval(r);
            case Neg(var inner)    -> -eval(inner);
            case Div(var l, var r) -> eval(l) / eval(r);
        };
    }

    static String show(Expr e) {
        return switch (e) {
            case Num(var v)        -> String.valueOf(v);
            case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
            case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
            case Neg(var inner)    -> "-" + show(inner);
            case Div(var l, var r) -> "(" + show(l) + " / " + show(r) + ")";
        };
    }
}
// Main.java
public class Main {
    public static void main(String[] args) {
        Expr e = new Div(new Mul(new Add(new Num(3), new Num(4)), new Neg(new Num(2))), new Num(4));
        System.out.println(Ops.show(e) + " = " + Ops.eval(e));
    }
}
$ javac *.java && java Main
(((3.0 + 4.0) * -2.0) / 4.0) = -3.5

Now do the same in the Visitor version. A new node needs its own accept, so put it in a new file next to the original Expr.java, Visitors.java and Main.java.

// Div.java
record Div(Expr left, Expr right) implements Expr {
    public <R> R accept(ExprVisitor<R> v) { return v.visitDiv(this); }
}
$ javac *.java && java Main
Div.java:3: error: cannot find symbol
    public <R> R accept(ExprVisitor<R> v) { return v.visitDiv(this); }
                                                    ^
  symbol:   method visitDiv(Div)
  location: variable v of type ExprVisitor<R>
  where R is a type-variable:
    R extends Object declared in method <R>accept(ExprVisitor<R>)
1 error

The first error is in the plumbing: ExprVisitor has no visitDiv. Add R visitDiv(Div d); below visitNeg in ExprVisitor and compile again.

$ javac *.java && java Main
Visitors.java:2: error: Eval is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
class Eval implements ExprVisitor<Double> {
^
Visitors.java:9: error: Show is not abstract and does not override abstract method visitDiv(Div) in ExprVisitor
class Show implements ExprVisitor<String> {
^
2 errors

Only now does the compiler reach the operations, at the Eval and Show class declarations. Once both have a visitDiv method, the program compiles. Both versions need the new node and one new case per operation: a visitDiv in each visitor, or a case in each switch. The Visitor version also needs plumbing the sealed one doesn’t have: an accept method on the node and a new method on the interface.

The trade-off, and what sealed types change

Visitor is the classic answer to the expression problem. In an ordinary class hierarchy, a new type is cheap: you write one subclass with its own methods. A new operation is expensive, because it means a new method in every class. Visitor swaps those costs. A new operation is just one more visitor class with no edits to the nodes. A new type means touching the visitor interface and every visitor, and the compiler checks that you did.

Sealed types plus pattern-matching switch give the same balance as Visitor with far less code. A new operation is one more method with a switch, and a new type is a compile error at every switch that misses it. What goes away is the scaffolding: the accept methods, the visitor interface and the double dispatch. The node types no longer have to be designed for visiting in advance, and an operation that needs extra input takes it as an ordinary parameter, not through visitor fields.

The safety still has one condition, the same as in the earlier MissingCase example: leave out default. A default branch in a switch, like a visitor base class with empty visitX methods, compiles quietly when a new type arrives. Visitor still makes sense on Java versions before 21, and for hierarchies that are not yours to seal, such as a library that already exposes accept (javax.lang.model’s ElementVisitor is one).

Guards and dominance

public class ClassifyDemo {
    sealed interface Shape permits Circle, Rectangle {}
    record Circle(double radius) implements Shape {}
    record Rectangle(double width, double height) implements Shape {}

    static String classify(Shape shape) {
        return switch (shape) {
            case Circle c when c.radius() > 10                 -> "Large circle";
            case Circle c                                      -> "Small circle";
            case Rectangle(var w, var h) when w == h           -> "Square";
            case Rectangle r                                   -> "Rectangle";
        };
    }

    public static void main(String[] args) {
        Shape[] shapes = { new Circle(15), new Circle(5), new Rectangle(4, 4), new Rectangle(3, 5) };
        for (Shape s : shapes) System.out.println(s + " -> " + classify(s));
    }
}
$ java ClassifyDemo.java
Circle[radius=15.0] -> Large circle
Circle[radius=5.0] -> Small circle
Rectangle[width=4.0, height=4.0] -> Square
Rectangle[width=3.0, height=5.0] -> Rectangle

A when guard adds a condition to a pattern. Cases are tried top to bottom, so the guarded Circle must come first. Put the plain case Circle c above it and the compiler rejects the program with this case label is dominated by a preceding case label — the guarded case could never be reached. Guarded cases do not count toward exhaustiveness, which is why the unguarded Circle and Rectangle cases are still required.

When to use sealed types

SituationUse
Fixed set of alternatives, each with different datasealed interface + records
Result/Either types, state machines, ASTssealed interface + records
Fixed set of alternatives with the same shapeenum
Open extension point or plugin APIregular interface
No subclassing at allfinal class

Practice

  1. Add a Sub(Expr left, Expr right) node to the expression tree, wire it into eval and show, and evaluate (10 - 4) * 2.
Solution
public class ExprSub {
    sealed interface Expr permits Num, Sub, Mul {}
    record Num(double value) implements Expr {}
    record Sub(Expr left, Expr right) implements Expr {}
    record Mul(Expr left, Expr right) implements Expr {}

    static double eval(Expr e) {
        return switch (e) {
            case Num(var v)        -> v;
            case Sub(var l, var r) -> eval(l) - eval(r);
            case Mul(var l, var r) -> eval(l) * eval(r);
        };
    }

    static String show(Expr e) {
        return switch (e) {
            case Num(var v)        -> String.valueOf(v);
            case Sub(var l, var r) -> "(" + show(l) + " - " + show(r) + ")";
            case Mul(var l, var r) -> "(" + show(l) + " * " + show(r) + ")";
        };
    }

    public static void main(String[] args) {
        Expr e = new Mul(new Sub(new Num(10), new Num(4)), new Num(2));
        System.out.println(show(e) + " = " + eval(e));
    }
}
$ java ExprSub.java
((10.0 - 4.0) * 2.0) = 12.0
  1. Add a ship(Status, String tracking) transition to the order example that only accepts Processing and throws from any other state.
Solution
public class OrderShip {
    sealed interface Status permits Pending, Processing, Shipped {}
    record Pending() implements Status {}
    record Processing(String warehouse) implements Status {}
    record Shipped(String tracking) implements Status {}

    static Status ship(Status s, String tracking) {
        return switch (s) {
            case Processing p -> new Shipped(tracking);
            case Pending p  -> throw new IllegalStateException("Cannot ship order in " + s);
            case Shipped sh -> throw new IllegalStateException("Already shipped: " + sh.tracking());
        };
    }

    public static void main(String[] args) {
        System.out.println(ship(new Processing("WH-1"), "1Z999"));
        try {
            ship(new Pending(), "1Z999");
        } catch (IllegalStateException e) {
            System.out.println("Caught: " + e.getMessage());
        }
    }
}
$ java OrderShip.java
Shipped[tracking=1Z999]
Caught: Cannot ship order in Pending[]

Listing the rejected states explicitly, instead of using default, keeps the switch exhaustive: a new state added later will force a decision here.

  1. Make Motorcycle in the first example sealed ... permits Scooter instead of non-sealed, and make Scooter final. Confirm it still runs.
Solution
public class SealedBranch {
    sealed interface Vehicle permits Car, Motorcycle {}
    record Car(int doors) implements Vehicle {}
    static sealed class Motorcycle implements Vehicle permits Scooter {}
    static final class Scooter extends Motorcycle {}

    public static void main(String[] args) {
        Vehicle v = new Scooter();
        String kind = switch (v) {
            case Car c        -> "car";
            case Scooter s    -> "scooter";
            case Motorcycle m -> "motorcycle";
        };
        System.out.println(kind);
    }
}
$ java SealedBranch.java
scooter

The seal now continues one level down, so the hierarchy is closed all the way to Scooter.

Frequently Asked Questions

What is the difference between a sealed class and a final class in Java?
A final class cannot be extended at all. A sealed class can be extended only by the classes named in its permits clause, so you get a closed, known set of subtypes rather than none. That closed set is what lets the compiler check that a switch over the type covers every case.
Do permitted subclasses have to be in the same file as the sealed class?
No. They must be in the same module, or in the same package when the code is in the unnamed module. If all subclasses are declared in the same source file as the sealed type, the permits clause can be omitted and the compiler infers it; otherwise you must list them explicitly.
Why must each permitted subclass be final, sealed or non-sealed?
The compiler forces you to decide how the hierarchy continues below each permitted subtype. final stops it, sealed continues it with its own permits list, and non-sealed reopens that branch to any subclass. Leaving out all three modifiers is a compile error.
Why use sealed types instead of an enum?
Enum constants are single instances that all share the same fields. Sealed subtypes are full classes or records, so each alternative can carry different data, such as a CreditCard with a card number and a BankTransfer with an IBAN, while the set of alternatives stays closed and switch statements stay exhaustive.
Do sealed classes replace the Visitor pattern in Java?
For hierarchies you control, largely yes. A sealed interface with an exhaustive pattern-matching switch gives the same compile-time guarantee as Visitor: add a new subtype and the compiler flags every operation that does not handle it. It does this without accept methods, a visitor interface or double dispatch, as long as the switch has no default branch.