An Introduction to Java Sealed Classes and Interfaces

In this article, we’ll explore what a sealed type in Java is, how to define a class and interface as sealed and finally we’ll go through a practical example.

Sealed class syntax

To have explicit control over the extensibility of a class we use identifier sealed and then permits to specify the set of classes allowed to extend:


sealed class Advice permits BeforeAdvice, Interceptor, AfterAdvice {

}

non-sealed class BeforeAdvice extends Advice {

}

final class Interceptor extends Advice {

}

sealed class AfterAdvice extends Advice {

}

final class AfterReturningAdvice extends AfterAdvice {

}

Sealed Advice Hierarchy Diagram

There are some basic rules to keep in mind:

  • subclasses are required to specify non-sealed if a class can be extended further, final or sealed
  • permitted subclasses must directly extend the sealed class
  • permits is optional if classes are found in the same source file
  • permitted direct subclasses must reside in the same package if the superclass is in an unnamed module
  • permitted direct subclasses can reside in different packages only when we have a named module, meaning we declare a module-info.java with appropriate content

Advantages of using sealed class or interface

Let’s talk first about exhaustiveness and why it might come in handy.

If you watch closely the evolution of OpenJDK, you probably saw a couple of releases ago switch expression and how it doesn’t need anymore a default case when dealing with enums:

enum TrafficLight {
    RED,
    YELLOW,
    GREEN
}

String signal(TrafficLight trafficLight) {
    return switch (trafficLight) {
        case RED -> "stop";
        case GREEN -> "go";
    };
}

The above code snippet when compiled throws the error message “switch expression does not cover all possible input values”, because the values of TrafficLight are known beforehand and the compiler detects that one case is not handled.

When it comes to inheritance and pattern matching, a feature which is due sometime in the next releases, we’ll be able to get rid of traditional if-else chain and use a more elegant switch expression:

switch (advice) {
    case BeforeAdvice ba -> ba.before();
    case Interceptor i -> i.invoke();
    case AfterAdvice aa -> aa.after();
};

instead of

if (advice instanceof BeforeAdvice ba) {
    ba.before();
} else if (advice instanceof Interceptor i) {
    i.invoke();
} else if (advice instanceof AfterAdvice aa) {
    aa.after();
} else {
    throw new IllegalStateException("this is not possible");
}

Next advantage is about restricting the use of a superclass or interface. Some people already struggled with similar problem in the past and came with rather wacky solutions (e.g. Do not inherit, please).

It’s especially useful when the API knows how to deal only with a set of predefined subclasses. Moreover, the developer doesn’t have to do defensive programming, as we saw in the previous example.

Restrictions

If an interface is declared as sealed we cannot use it as a lambda expression or create an anonymous class. Like final, sealed property and its list of permitted subtypes, are defined in the class file so that it can be enforced at runtime:

public sealed interface Period permits Hour, Day, Week, Month {

    void print();

    public static void main(String[] args) {
        //error: incompatible types: Period is not a functional interface
        Period period1 = () -> System.out.println("lambda period");

        //error: local classes must not extend sealed classes
        Period period2 = new Period() {
            @Override
            public void print() {
                System.out.println("anonymous class");
            }
        };
    }
}

Example

Let’s suppose we want to iterate over a list using the well-known head-tail method. Of course, it can be implemented in various ways, but for the sake of example we’ll do it with the help of a sealed interface:

import java.util.List;

public sealed interface Sequence<T> permits IterableOps, Nil {

    static <T> Sequence<T> of(T... values) {
        return new IterableOps<>(List.of(values));
    }
}

We need Sequence to be sealed because, for this particular example, we’ll always deal with only two cases: when there are some elements left in the list and when there are none (Nil), and we don’t want someone to mess with this hierarchy by “accidentally” implementing the interface.

Next, we define Nil which is a list of nothing.

public final class Nil implements Sequence {
    public static final Nil NIL = new Nil();
}

Lastly, we define IterableOps class:

import java.util.Iterator;

public final class IterableOps<T> implements Sequence<T> {
    private final Iterator<T> iterator;

    protected IterableOps(Iterable<T> iterable) {
        this.iterator = iterable.iterator();
    }

    public T head() {
        return iterator.next();
    }

    public Sequence<T> tail() {
        return iterator.hasNext() ? this : Nil.NIL;
    }
}

In main we’ll create a list of strings and use it in a for loop. Since pattern matching with switch is not yet available, will make use of the next best thing - enhanced instanceof (JEP-305). The stop condition is when we hit Nil:

public static void main(String[] args) {
    for(var seq = Sequence.of("apples", "oranges", "grapes");
        seq instanceof IterableOps<String> itOps;
        seq = itOps.tail()) {

        System.out.println(itOps.head());
    }
}

It prints out

apples
oranges
grapes

Conclusion

Without a doubt, starting with Java 15 we’ll give more consideration to whatever a class should be open for extension or sealed upon creation. The decision relies on the purpose of the class, be it code reuse, modeling various types that exist in a domain, or defending the integrity of an API.

Bibliography