Sealed Classes and Interfaces
Restrict which classes can extend or implement a type using sealed, permits, and non-sealed (Java 17+).
Overview
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.
Completed: 0 / 4 exercises
Knowledge Check
Question 1 of 4