Java 17 Sealed Classes Tutorial with Examples

Introduction In Java, a class is either open to extension by anyone, or final and closed to everyone. There was no way to say "this class can be extended, but only by these classes". Yet many designs need exactly that: a Shape which is a Circle, a Rectangle or a Triangle and nothing else, or a payment result which is either a success or a failure. Sealed classes, previewed in Java 15 and 16 and finalized in Java 17Click to read tutorial: Overview of New Features in Java 17 (JEP 409), let a class or interface list exactly which classes may extend or implement it. This tutorial explains sealed classes and interfaces with examples - the sealed and permits keywords, the rules for subclasses, sealed interfaces with records, the compiler errors for breaking the rules, and how sealed types make code exhaustive. Declaring a sealed class A class is sealed with the sealed modifier, and its permitted subclasses are listed after permits. Every permitted subclass must then declare how it continues the hierarchy, with exactly one of three modifiers -
  • final - it cannot be extended further,
  • sealed - it can be extended, but only by its own list of permitted subclasses, or
  • non-sealed - it is open again: any class may extend it.
A sealed class hierarchy
//SealedShapes.java
public class SealedShapes {
  static abstract sealed class Shape permits Circle, Rectangle, Polygon { }

  static final class Circle extends Shape {
    final double radius;
    Circle(double radius) { this.radius = radius; }
  }
  static sealed class Rectangle extends Shape permits Square {
    final double width, height;
    Rectangle(double width, double height) { this.width = width; this.height = height; }
  }
  static final class Square extends Rectangle {
    Square(double side) { super(side, side); }
  }
  static non-sealed class Polygon extends Shape { }     // open again
  static class Hexagon extends Polygon { }               // allowed: Polygon is non-sealed

  static double area(Shape shape) {
    if (shape instanceof Circle c) return Math.PI * c.radius * c.radius;
    if (shape instanceof Rectangle r) return r.width * r.height;
    return Double.NaN;                                  // polygons: not computed here
  }

  public static void main(String[] args) {
    Shape[] shapes = {new Circle(1), new Rectangle(2, 3), new Square(2), new Hexagon()};
    for (Shape s : shapes) {
      System.out.printf("%-9s area=%.2f%n", s.getClass().getSimpleName(), area(s));
    }
    System.out.println("Shape is sealed: " + Shape.class.isSealed());
    for (Class<?> c : Shape.class.getPermittedSubclasses()) {
      System.out.println("  permits " + c.getSimpleName());
    }
  }
}
 OUTPUT of the above code
Circle    area=3.14
Rectangle area=6.00
Square    area=4.00
Hexagon   area=NaN
Shape is sealed: true
  permits Circle
  permits Rectangle
  permits Polygon
Explanation of the code
  • Shape permits exactly three subclasses. No other class - in this file or anywhere else - can extend it.
  • Circle is final, Rectangle is itself sealed and permits only Square, and Polygon is non-sealed, so Hexagon (or any other class) may extend it. This lets the author decide, branch by branch, where the hierarchy is closed.
  • Reflection knows about sealing: Class.isSealed() and Class.getPermittedSubclasses() were added in Java 17.
  • The permitted subclasses must be in the same module as the sealed class - or, if it is not in a named module, in the same package. When they are in the same source file, as here, the permits clause may be left out and the compiler infers it.
Sealed interfaces and records Interfaces can be sealed too, and they combine naturally with recordsClick to read tutorial: Java 16 Records Tutorial with Examples, which are implicitly final. Together they model data which is "one of a fixed set of shapes" - what other languages call an algebraic data type or a sum type -
A sealed interface implemented by records
//PaymentResults.java
public class PaymentResults {
  sealed interface PaymentResult permits Approved, Declined, Pending { }
  record Approved(String transactionId, long amountCents) implements PaymentResult { }
  record Declined(String reason) implements PaymentResult { }
  record Pending(int retryAfterSeconds) implements PaymentResult { }

  static String message(PaymentResult result) {
    if (result instanceof Approved a) return "Paid " + a.amountCents() / 100.0 + " (txn " + a.transactionId() + ")";
    if (result instanceof Declined d) return "Declined: " + d.reason();
    if (result instanceof Pending p) return "Pending - retry in " + p.retryAfterSeconds() + "s";
    throw new AssertionError("impossible: PaymentResult is sealed");
  }

  public static void main(String[] args) {
    PaymentResult[] results = {new Approved("TX-1001", 49_900), new Declined("insufficient funds"), new Pending(30)};
    for (PaymentResult r : results) System.out.println(message(r));
  }
}
 OUTPUT of the above code
Paid 499.0 (txn TX-1001)
Declined: insufficient funds
Pending - retry in 30s
A method returning PaymentResult tells its callers precisely which outcomes to handle - documented by the type itself, and checked by the compiler. Rules and compiler errors
Breaking the rules of sealed classes
//SealedErrors.java
public class SealedErrors {
  sealed interface Vehicle permits Car, Bike { }
  final class Car implements Vehicle { }
  class Bike implements Vehicle { }            // missing final, sealed or non-sealed
  final class Truck implements Vehicle { }     // not in the permits list
}
 OUTPUT on compiling the above code
SealedErrors.java:5: error: sealed, non-sealed or final modifiers expected
  class Bike implements Vehicle { }            // missing final, sealed or non-sealed
  ^
SealedErrors.java:6: error: class is not allowed to extend sealed class: Vehicle (as it is not listed in its 'permits' clause)
  final class Truck implements Vehicle { }     // not in the permits list
        ^
2 errors
The compiler rejects a permitted subclass which does not say how the hierarchy continues, and any class which is not in the permits list. The JVM enforces the same rules at runtime, so they cannot be bypassed by generating classes.
Why sealed classes matter: exhaustiveness In the examples above, the code still needed a fallback - a return Double.NaN or a throw new AssertionError() - because if statements cannot know that all cases are covered. Sealed types were designed to pair with pattern matching for switchClick to read: pattern matching for switch, finalized in Java 21. A switch over a sealed type is exhaustive when it has a case for every permitted subtype - no default is needed, and if a new subtype is added later, every switch which does not handle it becomes a compile error. In Java 21 the message() method becomes -
Java 21: an exhaustive switch over a sealed interface
static String message(PaymentResult result) {
  return switch (result) {
    case Approved a -> "Paid " + a.amountCents() / 100.0;
    case Declined d -> "Declined: " + d.reason();
    case Pending p  -> "Pending - retry in " + p.retryAfterSeconds() + "s";
  };   // no default: the compiler knows these are all the cases
}
Summary In this tutorial we saw how sealed classes and interfaces, final in Java 17, restrict which classes may extend or implement them with sealed and permits, and how each permitted subclass continues the hierarchy as final, sealed or non-sealed. We modelled a fixed set of outcomes with a sealed interface and records, inspected sealing with reflection, and saw the compiler errors for breaking the rules. Lastly, we saw how sealed types make switches exhaustive from Java 21. Next, read about the new random number generators in Java 17Click to read tutorial: Java 17 Random Number Generators: RandomGenerator API.