Skip to content
Java modern java 7 min read

Sealed Classes

Sealed classes let you declare exactly which classes are allowed to extend or implement a type. Instead of leaving a class hierarchy open to anyone, you draw a clear boundary and say: “only these specific subtypes exist.” This makes your domain models more explicit and unlocks exhaustive pattern matching in switch expressions.

Why Sealed Classes?

Before sealed classes, you had two options for controlling inheritance:

  • Make a class final — nobody can extend it at all.
  • Leave it open — anyone, anywhere, can subclass it.

Neither extreme is always right. Sometimes you want a fixed, well-known set of subtypes — like Shape can only ever be Circle, Rectangle, or Triangle — but you still need each subtype to have its own implementation. Sealed classes fill that gap.

Note: Sealed classes were a preview feature in Java 15 (JEP 360) and Java 16 (JEP 397), then finalized in Java 17 (JEP 409). Use Java 17+ for production code.

Declaring a Sealed Class

Use the sealed modifier on the class, then list permitted subclasses with the permits clause.

public sealed class Shape permits Circle, Rectangle, Triangle {
    public abstract double area();
}

Each permitted subclass must choose one of three modifiers:

ModifierMeaning
finalCannot be extended further — the hierarchy ends here
sealedCan be extended, but only by its own permits list
non-sealedReopens the hierarchy — anyone can extend this subclass
public final class Circle extends Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public final class Rectangle extends Shape {
    private final double width, height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return width * height;
    }
}

public non-sealed class Triangle extends Shape {
    private final double base, height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double area() {
        return 0.5 * base * height;
    }
}

Because Triangle is non-sealed, anyone can extend it further. Circle and Rectangle are final, so the hierarchy stops there.

A Complete Example

public class SealedDemo {
    public static void main(String[] args) {
        Shape[] shapes = {
            new Circle(5),
            new Rectangle(4, 6),
            new Triangle(3, 8)
        };

        for (Shape s : shapes) {
            System.out.printf("%s -> area = %.2f%n",
                s.getClass().getSimpleName(), s.area());
        }
    }
}

Output:

Circle -> area = 78.54
Rectangle -> area = 24.00
Triangle -> area = 12.00

Sealed Interfaces

Sealed works on interfaces too — perfect for modeling sum types (a value that is exactly one of a fixed set of variants).

public sealed interface Result<T> permits Result.Success, Result.Failure {

    record Success<T>(T value) implements Result<T> {}
    record Failure<T>(String errorMessage) implements Result<T> {}
}

Tip: Combining sealed interfaces with Records is a powerful pattern. Records are implicitly final, so they satisfy the sealed contract without any extra modifier.

Using it:

public class ResultDemo {
    static Result<Integer> divide(int a, int b) {
        if (b == 0) return new Result.Failure<>("Division by zero");
        return new Result.Success<>(a / b);
    }

    public static void main(String[] args) {
        var r1 = divide(10, 2);
        var r2 = divide(10, 0);

        printResult(r1);
        printResult(r2);
    }

    static void printResult(Result<Integer> result) {
        // Exhaustive switch — compiler knows all possible types
        switch (result) {
            case Result.Success<Integer> s -> System.out.println("Value: " + s.value());
            case Result.Failure<Integer> f -> System.out.println("Error: " + f.errorMessage());
        }
    }
}

Output:

Value: 5
Error: Division by zero

Notice there is no default case needed — the compiler knows Result can only be Success or Failure, so the switch is exhaustive.

Sealed Classes and Pattern Matching

The real power of sealed classes shows up with pattern matching in switch expressions. Because the compiler knows the exact set of permitted types, it can verify that your switch covers every case.

public sealed interface Notification permits EmailNotification, SmsNotification, PushNotification {}

record EmailNotification(String to, String subject) implements Notification {}
record SmsNotification(String phone, String message) implements Notification {}
record PushNotification(String deviceToken, String body) implements Notification {}

public class NotificationSender {
    static String describe(Notification n) {
        return switch (n) {
            case EmailNotification e  -> "Email to " + e.to() + ": " + e.subject();
            case SmsNotification s    -> "SMS to " + s.phone() + ": " + s.message();
            case PushNotification p   -> "Push to device " + p.deviceToken();
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(new EmailNotification("[email protected]", "Hello")));
        System.out.println(describe(new SmsNotification("+1234567890", "Your code is 9821")));
        System.out.println(describe(new PushNotification("abc-token-123", "New message")));
    }
}

Output:

Email to [email protected]: Hello
SMS to +1234567890: Your code is 9821
Push to device abc-token-123

Warning: If you add a new permitted subtype to a sealed hierarchy later, every exhaustive switch in your code that covers that type will immediately fail to compile — the compiler tells you exactly where to update. This is a feature, not a bug: you cannot silently miss a case.

Permits Clause: When Can You Omit It?

If all permitted subclasses are defined in the same source file as the sealed class, you can omit the permits clause and the compiler infers it automatically.

// In file Expr.java — all types in one file, no permits needed
public sealed class Expr {
    record Num(int value)           extends Expr {}
    record Add(Expr left, Expr right) extends Expr {}
    record Mul(Expr left, Expr right) extends Expr {}
}

This is especially handy for small domain types. For larger hierarchies, explicit permits across multiple files is clearer.

Rules and Constraints

  • A sealed class and its permitted subclasses must be in the same package (or the same module if using the Java Module System — see Java 9: Modules).
  • Every permitted subclass must directly extend the sealed class — you cannot skip levels.
  • Permitted subclasses must be compiled together with the sealed class (they must be visible to each other at compile time).
  • Enums and records are implicitly final, so they automatically satisfy the constraint when listed in permits.
public sealed interface Status permits Status.Active, Status.Inactive, Status.Pending {
    // Using an enum that implements a sealed interface
    enum Active   implements Status {}
    enum Inactive implements Status {}
    enum Pending  implements Status {}
}

Note: Enums implementing sealed interfaces must be nested or co-located due to the same-package rule.

Under the Hood

At the JVM level, sealed classes are enforced entirely at compile time by the Java compiler. The .class file records the constraint using a PermittedSubclasses attribute (introduced in Java 17’s class file format, version 61). When the JVM loads a class, it checks this attribute and will throw an IncompatibleClassChangeError at load time if a class tries to extend a sealed type without being listed in permits — even if someone bypasses the Java compiler and crafts bytecode manually.

You can inspect the attribute with the javap tool:

javap -v Shape.class

You’ll see output like:

PermittedSubclasses:
  Circle
  Rectangle
  Triangle

From a JIT perspective, sealed hierarchies give the JVM extra information. Because the full set of subtypes is known and finite, the JIT compiler can apply more aggressive devirtualization and inlining on virtual method calls — improving performance compared to open hierarchies where any unknown subclass could appear at runtime.

Sealed classes also map naturally to what functional programmers call algebraic data types (ADTs) — specifically sum types (the value is exactly one of N variants). Languages like Kotlin (sealed class), Scala (sealed trait), Haskell, and Rust (enum) have had this concept for years. Java 17’s sealed classes bring it to mainstream Java.

Sealed Classes vs Other Approaches

ApproachControlled subtyping?Exhaustive switch?Extensible by library users?
final classOnly — no subtypingN/ANo
Open class/interfaceNoNoYes
sealed classYes — exact setYes (with pattern matching)Only via non-sealed
enumYes — fixed constantsYesNo
  • Pattern Matching — sealed classes are the foundation of exhaustive pattern matching in switch.
  • Switch Expressions — modern switch syntax that pairs perfectly with sealed hierarchies.
  • Records — records are implicitly final and work seamlessly as sealed subclasses.
  • Abstract Class — the classical alternative when you want a shared base but not a restricted hierarchy.
  • Interface — sealed interfaces let you apply the same fixed-hierarchy guarantee to interface types.
  • Modern Java (9–21) — the full picture of language features added from Java 9 through Java 21.
Last updated June 13, 2026
Was this helpful?