Getty Images

Tip

Use sealed classes in Java to control your inheritance

Need to limit the number of possible subclasses in your codebase? Here comes Java's sealed classes to your rescue.

Imagine you are an expert object-oriented Java developer who meticulously crafts code the way an artist cares for their masterpiece.

You believe clean code is an absolute necessity. Classes with clear responsibilities, well-defined boundaries and a proper inheritance hierarchy are your top priorities.

However, there's one frustrating limitation: You cannot prevent other developers from extending certain classes.

A method might accept a specific type and its subclasses, but you want to restrict usage to a select few implementations of your own design. Java's open inheritance model can feel like an open invitation to unintended extensions.

The problem becomes especially poignant when a switch statement or a nesting of if statements assumes that a complete collection of object types were enumerated over. If another developer attempts to extend your parent class and introduce a new object type, a once exhaustive evaluation becomes incomplete and ineffective.

Java does enable the option to mark a class as final to eliminate the ability to perform inheritance complete, but that is often too heavy of a hammer to drop. Marking a class as final prevents all inheritance, by anyone -- even the original developer cannot inherit from one of their own final classes.

It's a powerful but potentially messy design constraint.

Consider the following example:

public class TransactionProcessor {
   public void processTransaction(Transaction transaction) {
       // ... Logic to process the transaction
   }
}

You have only the following implementations that you want to support:

  • StockTransaction
  • BondTransaction
  • CryptoTransaction

If you leave the Transaction class non-final, anyone can create DodgyCoinTransaction or PonziSchemeTransaction subclasses. These could break the system, as there's no way to restrict what can extend Transaction. Making Transaction final prevents any subclassing, even your own. You'd be unable to refine transactions in the future, unless you modify the TransactionProcessor class directly but that's a violation of Java's open-closed principle.

What if you want more control over this process?

While there are workarounds -- for example, you could introduce a Factory class to generate transaction objects -- this adds complexity and could become a maintenance burden. Another approach could be a package-private superclass in the JDK.

However, an ideal solution would be to restrict the inheritance to a few specific classes. That's exactly where the sealed class comes into the picture.

What is a sealed class?

Sealed classes, proposed in JEP 409 and available since Java 17, let developers limit and control how deeply a component's type hierarchy can extend. With sealed classes, a developer can essentially create a closed-type system for their components.

Sealed classes address precisely the type of inheritance control previously described. Here's how we can refactor the above example:

public sealed class Transaction permits StockTransaction, BondTransaction, CryptoTransaction {
    // ... Common transaction properties and methods
}

The existing transaction classes can extend Transaction normally:

public final class StockTransaction extends Transaction {
}

How sealed classes work

The key concept with sealed classes is that they are declared using the sealed keyword, followed by the permits clause to explicitly name the classes that can extend or implement the sealed class or interface.

Consider the following simplified example:

public sealed interface Expr
       permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {  }

public final class ConstantExpr implements Expr {  }
public final class PlusExpr     implements Expr {  }
public final class TimesExpr    implements Expr {  }
public final class NegExpr      implements Expr {  }

In this example, only ConstantExpr, PlusExpr, TimesExpr and NegExpr can directly inherit from Expr. Any attempt to create another subclass will be met with a firm compiler error.

For example, if we want to write the following class and try to compile it, we get the following error:

javac DivideExpr.java
DivideExpr.java:1: error: no interface expected here
public class DivideExpr extends Expr {
                                ^
1 error

If you want to extend the Expr class, you must explicitly add the permits clause. Only a library developer can add that clause.

Sealed class rules

Sealed classes offer precise control over inheritance. Keep in mind the following rules:

  • A sealed class and all its permitted subclasses must be part of the same module. If they're in an unnamed module, they must also reside in the same package.
  • Permitted subclasses must directly extend the sealed class.
  • Every permitted subclass must use one of these modifiers:
    • final: No further extension allowed.
    • sealed: Extension is allowed but restricted to subclasses explicitly listed in the sealed class's permits clause.
    • non-sealed: The class is open for extension by other classes (reverts to traditional open inheritance).
  • The final modifier can be a special case of sealed where no subclasses are permitted.
  • Sealed and non-sealed classes can be abstract and have abstract members. Abstract permitted subclasses must be sealed or non-sealed.
  • The compiler generates errors if a class tries to extend a sealed class without being included in its permits clause.

The modifier non-sealed is the first hyphenated keyword proposed for Java. The non-sealed keyword brings flexibility into the rigid world of sealed classes, to allow a particular implementation to subclass.

The following is an example of a non-sealed class:

non-sealed class TimesExpr implements Expr {
}

This TimesExpr class is now open to being extended.

Benefits of sealed classes

Sealed classes bring several benefits to the table:

Better control. Firstly, sealed classes give developers more precise control over how their classes can be extended. This makes it easier to reason about code and maintain the integrity of the class hierarchy.

Enhance switch statements. Sealed classes allow for exhaustive checking in switch statements, to ensure all possible subclasses are addressed and to eliminate the need for a default clause. I will discuss the enhanced switch statement, including pattern matching, in a subsequent article.

More accurate fixed sets. Sealed classes enhance the accuracy with which you can model fixed sets of possibilities. They accurately reflect domain knowledge within the code.

Stronger, clearer usage patterns. In libraries and API development, sealed classes can improve effectiveness and clarify the intended usage patterns for developers using the code.

Sealed classes in Java represent a significant step forward in the language's ongoing evolution. By allowing developers to define closed hierarchies, sealed classes improve code safety, clarity and maintainability. Sealed classes make Java an even more powerful and expressive language and set the stage for even more sophisticated techniques such as pattern matching, which we'll explore in a subsequent article.

A N M Bazlur Rahman is a Java Champion and staff software developer at DNAstack. He is also founder and moderator of the Java User Group in Bangladesh.

Dig Deeper on Core Java APIs and programming techniques