When I first started working with Java, inheritance felt like a wide-open field where any class could extend another without much restriction. Over time, I realized this freedom could lead to messy code and hard-to-track bugs. That’s when I discovered sealed classes, a feature that brings order to inheritance by letting you control exactly which classes can extend or implement a type. It’s like having a guest list for a party—only invited guests can come in, and you know exactly who they are. This makes your code safer, easier to understand, and less prone to errors. In this article, I’ll walk you through ten practical techniques for using sealed classes in Java, with plenty of code examples and insights from my own experience. I’ll keep things simple and straightforward, so even if you’re new to this concept, you can follow along and start applying it in your projects.
Let me begin by explaining what sealed classes are in plain terms. In Java, a sealed class is one that you explicitly define to allow only certain other classes to extend it. You use the “sealed” keyword when declaring the class, followed by a “permits” clause that lists the permitted subclasses. This creates a closed hierarchy, meaning no other classes can sneak in and extend it without your permission. It’s a way to tell the compiler, “Hey, only these specific types are allowed to be part of this family.” This control helps in situations where you want to model real-world constraints, like different types of shapes in a graphics application, and ensure that no unexpected subclasses pop up later.
One of the first things I learned about sealed classes is how to declare them properly. Here’s a basic example to illustrate this. Suppose you’re building a system that handles different geometric shapes. You might have a base class called Shape, and you want only Circle, Rectangle, and Triangle to extend it. In the past, without sealed classes, any other class could extend Shape, leading to potential confusion. With sealed classes, you write it like this:
public sealed class Shape permits Circle, Rectangle, Triangle {
// Common properties or methods for all shapes
public abstract double area();
}
public final class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
public final class Rectangle extends Shape {
private final double width;
private final double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
public final class Triangle extends Shape {
private final double base;
private final double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return 0.5 * base * height;
}
}
In this code, the Shape class is sealed and only permits Circle, Rectangle, and Triangle to extend it. Each of these subclasses is marked as final, meaning they can’t be extended further. This setup ensures that the hierarchy is fixed and predictable. When I used this in a project, it made the code much easier to reason about because I knew exactly which shapes existed and could handle them all in a switch statement without worrying about missing cases.
Moving on to interfaces, sealed interfaces work in a similar way. They let you restrict which classes can implement them. This is handy when you have an interface that should only have a few specific implementations. For example, in a math expression evaluator, you might have an Expr interface for different types of expressions. Here’s how you could seal it:
public sealed interface Expr permits Constant, Add, Multiply {
double evaluate();
}
public record Constant(double value) implements Expr {
@Override
public double evaluate() {
return value;
}
}
public record Add(Expr left, Expr right) implements Expr {
@Override
public double evaluate() {
return left.evaluate() + right.evaluate();
}
}
public record Multiply(Expr left, Expr right) implements Expr {
@Override
public double evaluate() {
return left.evaluate() * right.evaluate();
}
}
In this case, the Expr interface is sealed and only allows Constant, Add, and Multiply to implement it. I’ve used records here because they’re perfect for simple data carriers, and they work seamlessly with sealed hierarchies. When I implemented something like this, it helped me avoid bugs because the compiler would flag any attempt to add a new expression type without updating the permits clause. This is especially useful in domains where the set of types is finite and well-defined, like in parsing or compiler design.
Sometimes, you don’t want the entire hierarchy to be closed. You might have a branch where you need more flexibility. That’s where non-sealed subclasses come in. A non-sealed subclass allows other classes to extend it, even if its parent is sealed. This is like having a gated community with one open area where anyone can build. For instance, in the Shape example, suppose you want Rectangle to be extendable for specific types like Square or RoundedRectangle. You can do this:
public sealed class Shape permits Circle, Rectangle, Triangle {
// Common methods
}
public non-sealed class Rectangle extends Shape {
protected double width;
protected double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}
public class Square extends Rectangle {
public Square(double side) {
super(side, side);
}
}
Here, Rectangle is marked as non-sealed, so Square can extend it. I found this useful in a UI framework where I had a base Widget class sealed to specific types, but one of them needed to support custom extensions. It gave me the best of both worlds—control at the top level and flexibility where needed.
On the other hand, if you want to ensure that a subclass is the end of the line and can’t be extended further, you mark it as final. This is common for leaf nodes in your hierarchy. For example, in the Shape hierarchy, Circle might be a final class because it doesn’t make sense to have subclasses of Circle in this context. Here’s a snippet:
public final class Circle extends Shape {
private final double radius;
public Circle(double radius) {
this.radius = radius;
}
// Methods as before
}
By making Circle final, you prevent any other class from extending it. In my experience, this is great for immutable data types or when you’re sure no further specialization is needed. It simplifies the code and reduces the risk of someone introducing a subclass that breaks your logic.
Sealed classes pair beautifully with records, which are a concise way to define data classes. Records are inherently final, so they fit perfectly as leaf nodes in a sealed hierarchy. Let’s expand on the Expr example with records:
public sealed interface Expr permits Constant, Add, Multiply {
double evaluate();
}
public record Constant(double value) implements Expr {
@Override
public double evaluate() {
return value;
}
}
public record Add(Expr left, Expr right) implements Expr {
@Override
public double evaluate() {
return left.evaluate() + right.evaluate();
}
}
public record Multiply(Expr left, Expr right) implements Expr {
@Override
public double evaluate() {
return left.evaluate() * right.evaluate();
}
}
Records automatically provide implementations for equals, hashCode, and toString, which saves a lot of boilerplate code. When I used this in a configuration parsing library, it made the code much cleaner and easier to maintain. The sealed interface ensured that all expression types were known, and records handled the data aspects efficiently.
One of the biggest advantages of sealed classes is how they enable exhaustive pattern matching. In switch expressions, the compiler can check that you’ve handled all permitted subtypes, so you don’t need a default case. This catches errors at compile time rather than runtime. For example, with the Shape hierarchy:
public String describe(Shape shape) {
return switch (shape) {
case Circle c -> "It's a circle with area: " + c.area();
case Rectangle r -> "It's a rectangle with area: " + r.area();
case Triangle t -> "It's a triangle with area: " + t.area();
};
}
In this switch expression, the compiler knows that Shape only permits Circle, Rectangle, and Triangle, so it ensures all cases are covered. If you add a new subclass to Shape without updating the switch, it will cause a compile error. I’ve used this in validation logic, and it’s saved me from missing edge cases multiple times. It’s like having a safety net that reminds you to update your code when the hierarchy changes.
Another technique involves using the permits clause with nested classes. If your permitted subclasses are inner classes within the same file, you don’t need to list them explicitly in the permits clause if they’re in the same compilation unit. This can make the code more cohesive. For example:
public sealed class Outer permits Outer.Inner {
public static final class Inner extends Outer {
// Implementation
}
}
Here, Inner is a nested class within Outer, and since it’s in the same file, the compiler understands the relationship without extra imports. I’ve used this in utility classes where the subclasses are tightly coupled to the parent, and it helped keep related code together.
Refactoring existing open hierarchies to sealed ones is a common task. Suppose you have an abstract class PaymentMethod with several subclasses, and you want to seal it to improve safety. You might start with something like this:
// Before: open hierarchy
public abstract class PaymentMethod {
public abstract void processPayment(double amount);
}
public class CreditCard extends PaymentMethod {
@Override
public void processPayment(double amount) {
// Process credit card payment
}
}
public class PayPal extends PaymentMethod {
@Override
public void processPayment(double amount) {
// Process PayPal payment
}
}
To refactor this into a sealed hierarchy, you change PaymentMethod to sealed and add a permits clause:
// After: sealed hierarchy
public sealed class PaymentMethod permits CreditCard, PayPal {
public abstract void processPayment(double amount);
}
public final class CreditCard extends PaymentMethod {
@Override
public void processPayment(double amount) {
// Implementation
}
}
public final class PayPal extends PaymentMethod {
@Override
public void processPayment(double amount) {
// Implementation
}
}
This transition is smooth because you’re not breaking existing code if the subclasses are already final or properly managed. In a project I worked on, this refactoring helped us eliminate a class of bugs where unauthorized subclasses were being added, and it made the payment processing code more robust.
Sealed classes provide compile-time safety checks, which is a huge benefit. If someone tries to create a class that extends a sealed class without being permitted, the compiler will throw an error. For instance:
public class IllegalShape extends Shape { } // This will not compile
This immediate feedback prevents inheritance violations early in the development process. I remember a time when a teammate accidentally extended a class that wasn’t meant to be subclassed, and it caused issues in production. With sealed classes, that kind of mistake is caught right away, saving debugging time.
Lastly, sealed types can be integrated with Java’s module system for enhanced encapsulation. Modules control which packages are visible, and sealed classes control inheritance within those packages. For example, if you have a module for shape classes:
module com.example.shapes {
exports com.example.shapes;
}
And in that module, you define sealed classes, you ensure that only permitted classes within the module or explicitly exported ones can extend them. This adds another layer of protection. In a large application, I used modules and sealed classes together to define clear boundaries between different parts of the system, which made the architecture more maintainable.
In conclusion, sealed classes in Java are a powerful tool for creating controlled, predictable type hierarchies. They help prevent inheritance misuse, enable exhaustive checks, and integrate well with other features like records and pattern matching. By applying these techniques, you can write code that’s safer, easier to understand, and more aligned with your domain model. I’ve found that starting with small hierarchies and gradually adopting sealed classes in refactoring has made my codebases more resilient. If you’re new to this, I recommend experimenting with simple examples to see how they fit into your workflow. The compiler’s help in enforcing these rules means you can focus on logic rather than worrying about unintended extensions.