What are they?

Sealed classes and interfaces[1] solve a unique problem that developers have been facing while trying to model real-world data using class hierarchies. As you know, with the concept of inheritance, all Java classes (that are not marked final) can be extended by any class to which they are visible. Same goes for implementing an interface. This stems from the object-oriented nature of the language.

There are, however, cases where this has to be restricted to better model the domain data. Namely when we want to represent a fixed set of different kinds of values. To some extent, we could use enum classes which can only have a fixed number of instances. However, the true restriction of inheritance comes with sealed classes and interfaces which can be extended or implemented only by those that are permitted to. This way, the author of the class/interface can specify which subclasses can extend it.

How to use them?

Code snippet below shows the syntax when using sealed classes. Similar syntax is used for interfaces as well.

public abstract sealed class BaseClass permits ClassA, ClassB { ... }

public final class ClassA extends BaseClass { ... }
public non-sealed class ClassB extends BaseClass { ... }

A sealed class is defined using the sealed keyword and it defines the subclasses that are allowed to extend it using the keyword permits. Every permitted subclass must explicitly extend the sealed base class as well as define one of the modifiers: final (it cannot be extended), sealed or non-sealed (it can be extended by any other class). Also note that all permitted subclasses must belong to the same module as the base class.

When to use them?

An example of a practical use case where sealed classes might be beneficial is when working with union types (a type which represents a value that can be one of several types). Let’s say we want to write a method that performs a certain operation which can result in an error, success or partial success. To represent the result of that operation, we could create an interface OperationResult which would then be implemented by Error, Success and PartialSuccess classes. While processing the result of such a method, many would be tempted to use instanceof in order to switch over all types of OperationResult but using instanceof on an interface is an anti-pattern. Such code would be really hard to maintain considering a new implementation of the interface could be added at any point. Considering this interface shouldn’t be implemented by any class other than these three, this is a perfect opportunity to make use of sealed interfaces where using instanceof is acceptable.

public sealed interface OperationResult 
    permits Success, PartialSuccess, Error { ... }

​​public final class Success implements OperationResult { ... }
​​public final class PartialSuccess implements OperationResult { ... }
public final class Error implements OperationResult { ... }

The prototype of the method could then look like this:

public OperationResult performOperation() { ... }

And to process the result of this method we could write the following:

final OperationResult result = performOperation();

if (result instanceof SuccessResult) {
    // Process success result
} else if (result instanceof PartialSuccessResult) {
    // Process partial success result
} else if (result instanceof  ErrorResult) {
    // Process error result
} else {
    // Handle default case
}

Since we are using a sealed interface that can be implemented only by an exhaustive number of classes, the code above is completely valid. Regardless, there is room for improvement with this code. The final else clause is unreachable but the compiler doesn’t know that. Similarly, omitting one of the conditions doesn’t produce any compile-time errors. All of this is ground for potential bugs.

To mend that, there is a new feature (preview in JDK 17) called pattern matching which will allow the switch statement to test against patterns. Hence, we would be able to rewrite the example above to the following:

final OperationResult result = performOperation();

switch (result) {
    case SuccessResult s -> { /* Process success result */ }
    case PartialSuccessResult ps -> { /* Process partial success */ }
    case ErrorResult e -> { /* Process error result */ }
}

Interesting thing is that there will be no need for the default case since the compiler knows which classes are permitted to implement the sealed interface. Omitting one of the cases will result in an error message. Pattern matching is a really interesting topic that goes much further than explained here. If that is something you wish to read more about, check out the JDK Enhancement Proposal which presents it.

Conclusion

Presented examples show some practical usages of sealed interfaces. With this feature we have more control over the hierarchies we are creating. Along with that, new pattern matching will encourage its usage. The final thing to note is that this feature should not be overused. When designing systems, think about the benefits of inheritance which is one of the fundamentals of object-oriented paradigm and keep in mind that it is almost always the desired solution. Similarly, improper usage might lead to violation of the encapsulation principle (where the abstract type knows too much about the actual implementation). However, if you find yourself in need to reduce the set of possible subclasses, with sealed classes and interfaces – there is now proper language support for that.

[1] This feature was released for preview in JDK 15, but it was finalized with the release of JDK 17.


“Sealed Classes and Interfaces in Java” Tech Bite was brought to you by Lamija Vrnjak, Software Engineer at Atlantbh.

Tech Bites are tips, tricks, snippets or explanations about various programming technologies and paradigms, which can help engineers with their everyday job.

 

Leave a Reply