Sealed classes are an exiting new feature of Java 17, let’s find out what they are, how to use them and why they are useful.

What are they

If you are unfamiliar with sealed classes the easiest way to describe them is as an enum that can have multiple instances of each value. It is basically an inheritance trick where you can guarantee that your superclass is only implemented by a few specific subclasses. This behaviour is actually quite useful from both a practical and a theoretical point of view, both of which we will discuss in this article.

Syntax

First, let’s see how they work.

abstract sealed class Root {
  final class A extends Root {}
  final class B extends Root {}
  sealed class C extends Root {}
  final class D extends C {}
  non-sealed class E extends C {}
}
final class F extends Root {}

This example contains almost all new keywords so let’s break them down. We see that the Root class is a sealed class with implementations A,B,C,E and F. In this example all classes are declared in the same file, this is mandatory (unless you use the new permits keyword we will discuss later). Any class that extends a sealed class must be either final, sealed or non-sealed. The final keyword just means that the class may not be extended any more. The non-sealed keyword opens a class for extension outside of the Root class (see class 'E'). Other classes will be able to extend these, but will count as 'E' when pattern matching over Root. A sealed class can also contain more sealed classes as long as they have at least 1 member (see class 'C' with member 'D').

It is also possible for Root to have member classes that are not in the same file. For this we need to use the new permits keyword which we can use to add classes that are in the same module or package. Do note that once we use the permits keyword we have to use it for all members, even if they are in the same file. The syntax for this is as follows:

public sealed class Root permits F, ClassInOtherFile { }
final class F extends Root {}


// in another file:
public final class ClassInOtherFile extends Root { }

Not only classes can be sealed, so can interfaces:

public sealed interface Root permits F, ClassInOtherFile { }
final class F implements Root {}

// in another file:
public final class ClassInOtherFile implements Root { }

Why are they good

Now we know how to make sealed classes, let’s talk about how they are useful. From a modelling perspective if you have a case where a class has a planned amount of subclasses (like days of week, planets in solar system etc..) it is now possible to express this in your java code. Previously it was possible to model this with an abstract superclass with a package private constructor and final subclasses, but you would get no compiler assistance when working with it. Using sealed classes, the compiler now knows all possible members of the sealed class and it allows for switch statements without the need for a default branch.

public void foo(Root a){
  switch (a){
    case F f -> System.out.println(f);
    case ClassInOtherFile c -> System.out.println(c);
  }
}

This is actually a big thing since this will force you to implement all cases and also gives you a compilation error if a new case is added. If root was not a sealed class and an extra implementation was added, it would execute in the default branch and it would have compiled normally, giving you no warning of possible changed behaviour that will occur at runtime. Thanks to sealed classes you can be explicit and enjoy compiler support.

Methodology

Sealed classes also give you the option of a more 'monadic' style of programming. We are now able to create exhaustive amount of implementations of a type. In function programming we call this kind of types sum types which is a ADT (Algebraic Data Type). To give an example of this I made a sum type implementation of the Optional class in Java.

import java.util.function.Function;
import java.util.function.Predicate;

public sealed interface Optional<T> {

  <O> Optional<O> flatMap(Function<T, Optional<O>> map);

  <O> Optional<O> map(Function<T, O> map);

  Optional<T> filter(Predicate<T> filter);
}

record Some<T>(T value) implements Optional<T> {

  @Override
  public <O> Optional<O> flatMap(Function<T, Optional<O>> map) {
    return map.apply(value);
  }

  @Override
  public <O> Optional<O> map(Function<T, O> map) {
    return new Some<>(map.apply(value));
  }

  @Override
  public Optional<T> filter(Predicate<T> filter) {
    return filter.test(value) ? new Some<>(value) : new None<>();
  }
}

//Cannot be a record since it does not contain a value
final class None<T> implements Optional<T> {

  @Override
  public <O> Optional<O> flatMap(Function<T, Optional<O>> map) {
    return new None<>();
  }

  @Override
  public <O> Optional<O> map(Function<T, O> map) {
    return new None<>();
  }

  @Override
  public Optional<T> filter(Predicate<T> filter) {
    return new None<>();
  }
}

Which uses pattern matching (preview option) to decide to see if the optional had a value or not.

public static Optional<String> foo(int bar){
  if(bar < 10) {
    return new Some<>("smaller than 10");
  }
  return new None<>();
}


public static void main(String[] args) {
  switch (foo(5)) {
    case None<String> n -> System.out.println("none");
    case Some<String> s -> System.out.println("some " + s.value());
  }
}

In this style we make full use of the fact that there can only be an exhaustive (in this case 2) amount of possible implementations of the Optional interface. Instead of having to do 'complex' if/else logic in all 3 functions (map, flatmap, filter) to see if the value has a value, we can now create a separate subclass for both cases and only handle that case here (which makes the None implementation quite easy). For some cases this trick can reduce quite a bit of complexity since you do not have to think about different code paths while implementing the subclass. This is just one of the many ways sum types can be beneficial, and they are incredibly popular in languages like Scala and can really help you design your domain model in a different way, but that is a topic for another blog.

Conclusion

Personally I think we will not see a lot of sealed classes in practice quite soon. This is not because they are not useful, but because we are not used to this way of thinking. Designing your domain model using these requires a different way of thinking which we are not used to as Java developer because we did not have the option to do so. I am excited that we now have the possibility to do so and slowly learn to embrace ADTs as a community.

shadow-left