⚡IntermediateModern Java

Sealed Classes and Interfaces

Restrict which classes can extend or implement a type using sealed, permits, and non-sealed (Java 17+).

Overview

A sealed class or interface restricts which other classes may extend or implement it. You list the permitted subtypes explicitly with the permits clause. Sealed types provide exhaustive type hierarchies: when you know all subtypes, the compiler can verify that a switch expression covers every case. Permitted subtypes must be in the same package (or module). Each permitted subtype must be declared as final, sealed, or non-sealed: - final: no further extension - sealed: further restricts subtypes - non-sealed: opens the hierarchy again

Key Points

  • •sealed class Shape permits Circle, Rectangle, Triangle { }
  • •Permitted subtypes must be in the same package/module
  • •Each permitted subtype must be final, sealed, or non-sealed
  • •final: the subtype cannot be extended further
  • •sealed: further restricts which classes can extend the subtype
  • •non-sealed: removes restrictions; anyone can extend
  • •Sealed types + pattern matching + switch = exhaustive type handling
  • •Records are implicitly final and can implement sealed interfaces

Examples

Sealed interface hierarchy

public class Main {
    sealed interface Shape permits Circle, Rectangle, Triangle {}

    record Circle(double radius) implements Shape {}
    record Rectangle(double width, double height) implements Shape {}
    record Triangle(double base, double height) implements Shape {}

    static double area(Shape shape) {
        return switch (shape) {
            case Circle c    -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
            case Triangle t  -> 0.5 * t.base() * t.height();
        };
    }

    static String describe(Shape shape) {
        return switch (shape) {
            case Circle c    -> "Circle with radius " + c.radius();
            case Rectangle r -> "Rectangle " + r.width() + "x" + r.height();
            case Triangle t  -> "Triangle base=" + t.base() + " height=" + t.height();
        };
    }

    public static void main(String[] args) {
        Shape[] shapes = {
            new Circle(5), new Rectangle(3, 4), new Triangle(6, 8)
        };
        for (var s : shapes) {
            System.out.printf("%s: area=%.2f%n", describe(s), area(s));
        }
    }
}

The switch on Shape is exhaustive: the compiler knows all three subtypes and does not require a default case. Adding a new permitted subtype would cause compile errors in all existing switches until they are updated.

Sealed class with final, sealed, and non-sealed subtypes

public class Main {
    sealed abstract class Vehicle permits Car, Truck, OpenVehicle {}

    final class Car extends Vehicle {
        String model;
        Car(String model) { this.model = model; }
        public String toString() { return "Car(" + model + ")"; }
    }

    final class Truck extends Vehicle {
        int tons;
        Truck(int tons) { this.tons = tons; }
        public String toString() { return "Truck(" + tons + "t)"; }
    }

    // non-sealed: anyone can extend OpenVehicle
    non-sealed class OpenVehicle extends Vehicle {}

    class Motorcycle extends OpenVehicle {
        public String toString() { return "Motorcycle"; }
    }

    public static void main(String[] args) {
        Main m = new Main();
        Vehicle[] vehicles = {
            m.new Car("Tesla"),
            m.new Truck(5),
            m.new Motorcycle()
        };
        for (var v : vehicles) {
            System.out.println(v);
        }
    }
}

non-sealed reopens the hierarchy. Motorcycle extends OpenVehicle without being listed in permits. The downside is the switch on Vehicle is no longer exhaustive because unknown subtypes of OpenVehicle can exist.

Sealed types for representing parse results

public class Main {
    sealed interface ParseResult permits Success, Failure {}

    record Success(int value) implements ParseResult {}
    record Failure(String error) implements ParseResult {}

    static ParseResult parse(String s) {
        try {
            return new Success(Integer.parseInt(s));
        } catch (NumberFormatException e) {
            return new Failure("Not a valid integer: " + s);
        }
    }

    static void process(ParseResult result) {
        switch (result) {
            case Success s  -> System.out.println("Parsed: " + s.value() * 2);
            case Failure f  -> System.out.println("Error: " + f.error());
        }
    }

    public static void main(String[] args) {
        String[] inputs = {"42", "abc", "100", "xyz"};
        for (String input : inputs) {
            process(parse(input));
        }
    }
}

Sealed interfaces are excellent for error handling without exceptions. ParseResult is either a Success or Failure, and the caller is forced by the type system to handle both cases.

Common Mistakes

  • Forgetting to mark permitted subtypes as final, sealed, or non-sealed. Every permitted subtype must have one of these modifiers
  • Putting permitted subtypes in a different package. Permitted subtypes must be in the same package (or module) as the sealed type
  • Using a default case in a switch on a sealed type. The default hides new subtypes from the compiler. Remove it to get exhaustiveness checking

Note

Sealed types are most powerful when combined with records and switch pattern matching. A sealed interface with record implementations gives you an algebraic data type: a type with a fixed set of shapes, each carrying its own data.

Exercises

Write Java code and click Run to execute it instantly. When your output looks correct, mark as done.

Loading editor…

Completed: 0 / 4 exercises

Knowledge Check

Question 1 of 4

What keyword restricts which classes can extend a type?