Key Takeaways
- The release of Java SE 15 in Sept 2020 will introduce "sealed classes" (JEP 360) as a preview feature.
- A sealed class is a class or interface which restricts which other classes or interfaces may extend it.
- Sealed classes, like enums, capture alternatives in domain models, allowing programmers and compilers to reason about exhaustiveness.
- Sealed classes are also useful for creating secure hierarchies by decoupling accessibility from extensibility, allowing library developers to expose interfaces while still controlling all the implementations.
- Sealed classes work together with records and pattern matching to support a more data-centric form of programming.
Java SE 15 (Sept 2020) introduces sealed classes as a preview feature. Sealing allows classes and interfaces to have more control over their permitted subtypes; this is useful both for general domain modeling and for building more secure platform libraries.
A class or interface may be declared sealed
, which means that only a specific set of classes or interfaces may directly extend it:
sealed interface Shape
permits Circle, Rectangle { ... }
This declares a sealed interface called Shape
. The permits
list means that only Circle
and Rectangle
may implement Shape
. (In some cases, the compiler may be able to infer the permits clause for us.) Any other class or interface that attempts to extend Shape
will receive a compilation error (or a runtime error, if you try to cheat and generate an off-label classfile which declares Shape
as a supertype.)
We are already familiar with the notion of restricting extension through final
classes; sealing can be thought of as a generalization of finality. Restricting the set of permitted subtypes may lead to two benefits: the author of a supertype can better reason about the possible implementations since they can control all the implementations, and the compiler can better reason about exhaustiveness (such as in switch
statements or cast conversion.) Sealed classes also pair nicely with records.
Sum and product types
The interface declaration above makes the statement that a Shape
can be either a Circle
or a Rectangle
and nothing else. To put it another way, the set of all Shape
s is equal to the set of all Circle
s plus the set of all Rectangles
s. For this reason, sealed classes are often called sum types, because their value set is the sum of the value sets of a fixed list of other types. Sum types, and sealed classes, are not a new thing; for example, Scala also has sealed classes, and Haskell and ML have primitives for defining sum types (sometimes called tagged unions or discriminated unions.)
Sum types are frequently found alongside product types. Records, recently introduced to Java, are a form of product type, so called because their state space is (a subset of) the cartesian product of the state spaces of their components. (If this sounds complicated, think of product types as tuples, and records as nominal tuples.) Let's finish the declaration of Shape
using records to declare the subtypes:
sealed interface Shape
permits Circle, Rectangle {
record Circle(Point center, int radius) implements Shape { }
record Rectangle(Point lowerLeft, Point upperRight) implements Shape { }
}
Here we see how sum and product types go together; we are able to say "a circle is defined by a center and a radius", "a rectangle is defined by two points", and finally "a shape is either a circle or a rectangle". Because we expect it will be common to co-declare the base type with its implementations in this manner, when all the subtypes are declared in the same compilation unit we allow the permits
clause to be omitted and infer it to be the set of subtypes declared in that compilation unit:
sealed interface Shape {
record Circle(Point center, int radius) implements Shape { }
record Rectangle(Point lowerLeft, Point upperRight) implements Shape { }
}
Wait, isn't this violating encapsulation?
Historically, object-oriented modeling has encouraged us to hide the set of implementations of an abstract type. We have been discouraged from asking "what are the possible subtypes of Shape
", and similarly told that downcasting to a specific implementation class is a "code smell". So why are we suddenly adding language features that seemingly go against these long-standing principles? (We could also ask the same question about records: isn't it violating encapsulation to mandate a specific relationship between a classes representation and its API?)
The answer is, of course, "it depends." When modeling an abstract service, it is a positive benefit that clients interact only with the service through an abstract type, as this reduces coupling and maximizes flexibility to evolve the system. However, when modeling a specific domain where the characteristics of that domain are already well known, encapsulation may have less to offer us. As we saw with records, when modeling something as prosaic as an XY point or an RGB color, using the full generality of objects to model data requires both a lot of low-value work and worse, can often obfuscate what is actually going on. In cases like this, encapsulation has costs not justified by its benefit; modeling data as data is simpler and more direct.
The same arguments apply to sealed classes. When modeling a well-understood and stable domain, the encapsulation of "I'm not going to tell you what kinds of shapes there are" does not necessarily confer the benefits that we would hope to get from the opaque abstraction, and may even make it harder for clients to work with what is actually a simple domain.
This doesn't mean that encapsulation is a mistake; it merely means that sometimes the balance of costs and benefits are out of line, and we can use judgment to determine when it helps and when it gets in the way. When choosing whether to expose or hide the implementation, we have to be clear about what the benefits and costs of encapsulation are. Is it buying us flexibility for evolving the implementation, or is it merely an information-destroying barrier in the way of something that is already obvious to the other side? Often, the benefits of encapsulation are substantial, but in cases of simple hierarchies that model well-understood domains, the overhead of declaring bulletproof abstractions can sometimes exceed the benefit.
When a type like Shape
commits not only to its interface but to the classes that implement it, we can feel better about asking "are you a circle" and casting to Circle
, since Shape
specifically named Circle
as one of its known subtypes. Just as records are a more transparent kind of class, sums are a more transparent kind of polymorphism. This is why sums and products are so frequently seen together; they both represent a similar sort of tradeoff between transparency and abstraction, so where one makes sense, the other is likely to as well. (Sums of products are frequently referred to as algebraic data types.)
Exhaustiveness
Sealed classes like Shape
commit to an exhaustive list of possible subtypes, which helps both programmers and compilers reason about shapes in a way that we couldn't without this information. (Other tools can take advantage of this information as well; the Javadoc tool lists the permitted subtypes in the generated documentation page for a sealed class.)
Java SE 14 introduces a limited form of pattern matching, which will be extended in the future. The first version allows us to use type patterns in instanceof
:
if (shape instanceof Circle c) {
// compiler has already cast shape to Circle for us, and bound it to c
System.out.printf("Circle of radius %d%n", c.radius());
}
It is a short hop from there to using type patterns in switch
. (This is not supported in Java SE 15, but is coming soon.) When we get there, we can compute the area of a shape using a switch expression whose case
labels are type patterns, as follows1:
float area = switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> Math.abs((r.upperRight().y() - r.lowerLeft().y())
* (r.upperRight().x() - r.lowerLeft().x()));
// no default needed!
}
The contribution of sealing here is that we did not need a default
clause, because the compiler knew from the declaration of Shape
that Circle
and Rectangle
cover all shapes, and so a default
clause would be unreachable in the above switch
. (The compiler still silently inserts a throwing default clause in switch expressions, just in case the permitted subtypes of Shape
have changed between compile and run time, but there is no need to insist that the programmer write this default clause "just in case".) This is similar to how we treat another source of exhaustiveness -- a switch expression over an enum
that covers all the known constants also does not need a default clause (and it is generally a good idea to omit it in this case, as this is more likely to alert us to having missed a case.)
A hierarchy like Shape
gives its clients a choice: they can deal with shapes entirely through their abstract interface, but they also can "unfold" the abstraction and interact through sharper types when it makes sense to. Language features such as pattern matching make this sort of unfolding more pleasant to read and write.
Examples of algebraic data types
The "sum of products" pattern can be a powerful one. In order for it to be appropriate, it must be extremely unlikely that the list of subtypes will change, and we anticipate that it will be easier and more useful for clients to discriminate over the subtypes directly.
Committing to a fixed set of subtypes, and encouraging clients to use those subtypes directly, is a form of tight coupling. All things being equal, we are encouraged to use loose coupling in our designs to maximize the flexibility to change things in the future, but such loose coupling also has a cost. Having both "opaque" and "transparent" abstractions in our language allows us to choose the right tool for the situation.
One place where we might have used a sum of products (had this been an option at the time) is in the API of java.util.concurrent.Future
. A Future
represents a computation that may run concurrently with its initiator; the computation represented by a Future
may have not yet been started, been started but not yet completed, already completed either successfully or with an exception, have timed out, or have been canceled by the interruption. The get()
method of Future
reflects all these possibilities:
interface Future<V> {
...
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
If the computation has not yet finished, get()
blocks until one of the completion modes occur, and if successful, returns the result of the computation. If the computation completed by throwing an exception, this exception is wrapped in an ExecutionException
; if the computation timed out or was interrupted, a different kind of exception is thrown. This API is quite precise, but is somewhat painful to use because there are multiple control paths, the normal path (where get()
returns a value) and numerous failure paths, each of which must be handled in a catch
block:
try {
V v = future.get();
// handle normal completion
}
catch (TimeoutException e) {
// handle timeout
}
catch (InterruptedException e) {
// handle cancelation
}
catch (ExecutionException e) {
Throwable cause = e.getCause();
// handle task failure
}
If we had sealed classes, records, and pattern matching when Future
was introduced in Java 5, its possible we would have defined the return type as follows:
sealed interface AsyncReturn<V> {
record Success<V>(V result) implements AsyncReturn<V> { }
record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
record Timeout<V>() implements AsyncReturn<V> { }
record Interrupted<V>() implements AsyncReturn<V> { }
}
...
interface Future<V> {
AsyncReturn<V> get();
}
Here, we are saying that an async result is either a success (which carries a return value), a failure (which carries an exception), a timeout, or a cancelation. This is a more uniform description of the possible outcomes, rather than describing some of them with the return value and others with exceptions. Clients would still have to deal with all the cases -- there's no way around the fact that the task might fail -- but we can handle the cases uniformly (and more compactly)1:
AsyncResult<V> r = future.get();
switch (r) {
case Success(var result): ...
case Failure(Throwable cause): ...
case Timeout(), Interrupted(): ...
}
Sums of products are generalized enums
A good way to think about sums of products is that they are a generalization of enums. An enum declaration declares a type with an exhaustive set of constant instances:
enum Planet { MERCURY, VENUS, EARTH, ... }
It is possible to associate data with each constant, such as the mass and radius of the planet:
enum Planet {
MERCURY (3.303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814e6),
...
}
Generalizing a little bit, a sealed class enumerates not a fixed list of instances of the sealed class, but a fixed list of kinds of instances. For example, this sealed interface lists various kinds of celestial bodies, and the data relevant to each kind:
sealed interface Celestial {
record Planet(String name, double mass, double radius)
implements Celestial {}
record Star(String name, double mass, double temperature)
implements Celestial {}
record Comet(String name, double period, LocalDateTime lastSeen)
implements Celestial {}
}
Just as you can switch exhaustively over enum constants, you will also be able to switch exhaustively over the various kinds of celestial bodies1:
switch (celestial) {
case Planet(String name, double mass, double radius): ...
case Star(String name, double mass, double temp): ...
case Comet(String name, double period, LocalDateTime lastSeen): ...
}
Examples of this pattern show up everywhere: events in a UI system, return codes in a service-oriented system, messages in a protocol, etc.
More secure hierarchies
So far, we've talked about when sealed classes are useful for incorporating alternatives into domain models. Sealed classes also have another, quite different, application: secure hierarchies.
Java has always allows us to say "this class cannot be extended" by marking the class final
. The existence of final
in the language acknowledges a basic fact about classes: sometimes they are designed to be extended, and sometimes they are not, and we would like to support both of these modes. Indeed, Effective Java recommends that we "Design and document for extension, or else prohibit it". This is excellent advice, and might be taken more often if the language gave us more help in doing so.
Unfortunately, the language fails to help us here in two ways: the default for classes is extensible rather than final, and the final
mechanism is in reality quite weak, in that it forces authors to choose between constraining extension and using polymorphism as an implementation technique. A good example where we pay for this tension is String
; it is critical to the security of the platform that strings be immutable, and therefore String
cannot be publicly extensible -- but it would be quite convenient for the implementation to have multiple subtypes. (The cost of working around this is substantial; Compact strings delivered significant footprint and performance improvements by giving special treatment to strings consisting exclusively of Latin-1
characters, but it would have been far easier and cheaper to do this if String
were a sealed
class instead of a final
one.)
It is a well-known trick for simulating the effect of sealing classes (but not interfaces) by using a package-private constructor, and putting all the implementations in the same package. This helps, but it is still somewhat uncomfortable to expose a public abstract class that is not meant to be extended. Library authors would prefer to use interfaces to expose opaque abstractions; abstract classes were meant to be an implementation aid, not a modeling tool. (See Effective Java, "Prefer interfaces to abstract classes".)
With sealed interfaces, library authors no longer have to choose between using polymorphism as an implementation technique, permitting uncontrolled extension, or exposing abstractions as interfaces -- they can have all three. In such a situation, the author may choose to make the implementation classes accessible, but more likely, the implementation classes will remain encapsulated.
Sealed classes allow library authors to decouple accessibility from extensibility. It's nice to have this flexibility, but when should we use it? Surely we would not want to seal interfaces like List
-- it is totally reasonable and desirable for users to create new kinds of List
. Sealing may have costs (users can't create new implementations) and benefits (the implementation can reason globally about all implementations); we should save sealing for when the benefits exceed the costs.
The fine print
The sealed
modifier may be applied to classes or interfaces. It is an error to attempt to seal an already-final class, whether explicitly declared with the final
modifier, or implicitly final (such as enum and record classes.)
A sealed class has a permits
list, which are the only permitted direct subtypes; these must be available at the time the sealed class is compiled, must actually be subtypes of the sealed class and must be in the same module as the sealed class (or in the same package, if in the unnamed module.) This requirement effectively means that they must be co-maintained with the sealed class, which is a reasonable requirement for such tight coupling.
If the permitted subtypes are all declared in the same compilation unit as the sealed class, the permits
clause may be omitted and will be inferred to be all subtypes in the same compilation unit. A sealed class may not be used as a functional interface for a lambda expression, or as the base type for an anonymous class.
Subtypes of a sealed class must be more explicit about their extensibility; a subtype of a sealed class must be either sealed
, final
, or explicitly marked non-sealed
. (Records and enums are implicitly final
, so do not need to be explicitly marked as such.) It is an error to mark a class or interface as non-sealed
if it does not have a sealed
direct supertype.
It is a binary- and source-compatible change to make an existing final
class sealed
. It is neither binary- nor source-compatible to seal a non-final class for which you do not already control all the implementations. It is binary compatible but not source-compatible to add new permitted subtypes to a sealed class (this might break the exhaustiveness of switch
expressions.)
Wrap-up
Sealed classes have multiple uses; they are useful as a domain modeling technique when it makes sense to capture an exhaustive set of alternatives in the domain model; they are also useful as an implementation technique when it is desirable to decouple accessibility from extensibility. Sealed types are a natural complement to records, as together they form a common pattern known as algebraic data types; they are also a natural fit for pattern matching, which are coming to Java soon as well.
Footnotes
1This example uses a form of switch expression -- one that uses patterns as case labels -- that is not yet supported by the Java Language. The six-month release-cadence allows us to co-design features but deliver them independently; we fully expect that switch will be able to use patterns as case labels in the near future.
About the Author
Brian Goetz is the Java Language Architect at Oracle and was the specification lead for JSR-335 (Lambda Expressions for the Java Programming Language.) He is the author of the best-selling Java Concurrency in Practice and has been fascinated by programming since Jimmy Carter was President.