Skip to content
Chapter 50 · Core Java

Java 17: Sealed Classes and Interfaces ​

Java has always given you inheritance as one of its most powerful tools. You create a class or interface, and any other code anywhere in the project can extend or implement it. That freedom sounds great on the surface, but it hides a real engineering problem that grows over time. Sealed classes and interfaces, introduced as a stable feature in Java 17, give you a precise solution to that problem. By the end of this article you will understand exactly what that problem is, how sealed classes fix it, all three options you must choose between for subclasses, the colocation rule, how sealed interfaces work, how sealed hierarchies combine beautifully with pattern matching, and every interview question you are likely to face on this topic.

The Problem: Uncontrolled Inheritance ​

Imagine you are designing a shapes library. You create an interface called Shape. Your intent is that this interface should represent geometric shapes that your rendering engine knows how to draw. You plan for Circle and Rectangle. Those are the two shapes your system supports today.

Now here is what actually happens. Because Java inheritance is open by default, any class anywhere can say implements Shape. A junior developer on your team adds a Triangle. A third party library you pull in adds a Star. Six months later someone adds a FreeFormPolygon. None of these were part of your design, but Java allowed every single one of them in without asking you.

The damage shows up when you write code that handles shapes. You end up writing something like this:

java
// Before sealed classes: defensive code everywhere
void render(Shape shape) {
    if (shape instanceof Circle) {
        renderCircle((Circle) shape);
    } else if (shape instanceof Rectangle) {
        renderRectangle((Rectangle) shape);
    } else {
        // Some unknown shape crept in. What do we do?
        throw new IllegalArgumentException("Unknown shape: " + shape.getClass());
    }
}

That else branch is a warning sign. You are writing defensive code to handle implementations you did not plan for and cannot control. The same problem shows up in switch statements where you always need a default case, not because the logic calls for one, but because you genuinely do not know what might arrive at runtime.

The deeper issue is that silently broken behavior becomes possible. If someone adds a new shape and you have a switch somewhere that handles shapes without a default, the new shape falls through doing nothing. No compile error. No runtime exception. Just silent wrong behavior. This is exactly the kind of bug that reaches production.

The root cause is simple: you have zero control over who extends or implements your type. Java, by design, made extension completely open. Sealed classes and interfaces exist to give you that control back.

Sealed Classes: Taking Back Control ​

The fix is the sealed keyword combined with the permits clause. Together they let you declare a type and explicitly name every class or interface that is allowed to directly extend or implement it. Anyone not on that list is rejected at compile time.

Here is the shape example rewritten with sealed:

java
// The sealed interface: only Circle and Rectangle are permitted
public sealed interface Shape permits Circle, Rectangle {
    double area();
}

Now try to create a Triangle that implements Shape:

java
// Compile error! Triangle is not in the permits list
public class Triangle implements Shape {
    public double area() { return 0.5 * base * height; }
}

The compiler stops this immediately. You have full control over your type hierarchy. No surprises, no unknown subtypes sneaking in.

The Three Subclass Options ​

Here is where things get interesting. When a class or interface appears in a permits list, it does not just inherit freely. Java requires you to make a conscious decision about what happens below that class in the hierarchy. You must choose exactly one of three options: final, sealed with its own permits, or the non-sealed modifier.

Think of this as being forced to make a deliberate architectural decision at every level of the tree. You cannot leave the hierarchy open by accident.

Option 1: final ​

Use final when you want this class to be a dead end. No subclasses. This branch of the hierarchy stops here.

java
// Circle is final: no class can extend Circle
public final class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    public double area() {
        return Math.PI * radius * radius;
    }
}

Choosing final says: I know exactly what this type is. It is complete. Nobody should be able to specialize it further. This is the strongest form of control.

Option 2: sealed with its own permits ​

Use sealed again when you want to allow subclasses but still restrict which ones. You are continuing the controlled hierarchy one level deeper.

java
// Rectangle is sealed: only ColoredRectangle and PlainRectangle are allowed
public sealed class Rectangle implements Shape permits ColoredRectangle, PlainRectangle {
    protected final double width;
    protected final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public double area() {
        return width * height;
    }
}

// ColoredRectangle is final: hierarchy ends here
public final class ColoredRectangle extends Rectangle {
    private final String color;

    public ColoredRectangle(double width, double height, String color) {
        super(width, height);
        this.color = color;
    }
}

// PlainRectangle is also final
public final class PlainRectangle extends Rectangle {
    public PlainRectangle(double width, double height) {
        super(width, height);
    }
}

You can chain sealed hierarchies as deeply as you need. At each level you decide: restrict further with sealed, stop with final, or open the branch back up.

Option 3: non-sealed ​

Use the non-sealed modifier when you deliberately want to open this branch back up to unrestricted extension. This is your escape valve when you need part of the hierarchy to be controlled and part of it to be open.

java
// Polygon is non-sealed: any class can extend it freely
public non-sealed interface Polygon extends Shape {
    int numberOfSides();
}

// Any class can now implement Polygon without restriction
public class Hexagon implements Polygon {
    public int numberOfSides() { return 6; }
    public double area() { /* calculation */ return 0; }
}

public class Pentagon implements Polygon {
    public int numberOfSides() { return 5; }
    public double area() { /* calculation */ return 0; }
}

Notice that the keyword is spelled non-sealed, with a hyphen. You are explicitly opting this branch out of the sealing restriction. The choice is still intentional, which is the point. Nothing in a sealed hierarchy is open by accident. You either closed the branch with final, continued restricting it with sealed, or consciously opened it again with non-sealed. There is no fourth option where a branch just stays undefined.

Where the Subclasses Must Live ​

The sealed type and its permitted subclasses must be in the same package (or, if you use Java modules, the same module). For example, if Shape is in com.example.shapes, then putting Circle in com.example.shapes.round is a compile error in a normal (non-module) project.

A Complete Working Example ​

Let us build the full hierarchy to see all three options working together: a sealed Shape interface permitting Circle, Polygon, and AbstractShape, with each branch taking a different direction.

java
// Top level sealed interface
public sealed interface Shape permits Circle, Polygon, AbstractShape {
    double area();
}

// Circle: final, no further subclasses
public final class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    public double area() {
        return Math.PI * radius * radius;
    }
}

// Polygon: non-sealed, open for anyone to implement
public non-sealed interface Polygon extends Shape {
    int numberOfSides();
}

// Hexagon can implement Polygon freely because Polygon is non-sealed
public class Hexagon implements Polygon {
    private final double side;

    public Hexagon(double side) {
        this.side = side;
    }

    public int numberOfSides() { return 6; }

    public double area() {
        return (3 * Math.sqrt(3) / 2) * side * side;
    }
}

// AbstractShape: sealed, restricts its own children to Rectangle and Triangle
public abstract sealed class AbstractShape implements Shape permits Rectangle, Triangle {
    // Common shape behavior goes here
    public String describe() {
        return "I am a " + getClass().getSimpleName();
    }
}

// Rectangle: final, this branch ends here
public final class Rectangle extends AbstractShape {
    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public double area() {
        return width * height;
    }
}

// Triangle: non-sealed, open for further specialization
public non-sealed class Triangle extends AbstractShape {
    protected final double base;
    protected final double height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    public double area() {
        return 0.5 * base * height;
    }
}

// RightTriangle can extend Triangle because Triangle is non-sealed
public class RightTriangle extends Triangle {
    public RightTriangle(double base, double height) {
        super(base, height);
    }
}

Look at the shape hierarchy now. Starting from Shape, you know with certainty that the only direct implementations are Circle, Polygon, and AbstractShape. Under AbstractShape the only direct subclasses are Rectangle and Triangle. Under Polygon and Triangle, things are open again. This is an intentional, readable, and maintainable design. Every branch either terminates with final, continues restricting with sealed, or deliberately opens with non-sealed. Nothing is left to chance.

Sealed Interfaces ​

Everything you just learned about sealed classes applies equally to interfaces. The sealed keyword works on interfaces exactly the same way. An interface that is sealed can only be implemented or extended by the types listed in its permits clause.

java
// A sealed interface for payment methods
public sealed interface PaymentMethod permits CreditCard, BankTransfer, Cryptocurrency {
    void processPayment(double amount);
}

// Each permitted type must choose: final, sealed, or non-sealed
public final class CreditCard implements PaymentMethod {
    public void processPayment(double amount) {
        System.out.println("Charging credit card: " + amount);
    }
}

public final class BankTransfer implements PaymentMethod {
    public void processPayment(double amount) {
        System.out.println("Initiating bank transfer: " + amount);
    }
}

// Cryptocurrency is non-sealed: different crypto types can extend it freely
public non-sealed class Cryptocurrency implements PaymentMethod {
    public void processPayment(double amount) {
        System.out.println("Processing crypto payment: " + amount);
    }
}

An interface in the permits list can also extend the sealed interface. A class in the permits list implements it. Both are valid. Both must still choose between the three options.

Why This Helps with switch ​

Because the compiler knows the complete list of subtypes, a switch over a sealed type can cover every case without a default (pattern matching for switch, Java 21). That is covered in the pattern matching for switch chapter.

The Rules at a Glance ​

The permitted subclass must be a direct subclass or implementor of the sealed type. You cannot have an intermediate class between the sealed parent and the permitted child. If Shape is sealed and Circle is in its permits list, then Circle must directly implement Shape. You cannot have Shape at the top, then some class GeometricForm in the middle, and then Circle extending GeometricForm while still being in Shape's permits.

Every permitted subclass must explicitly choose final, sealed, or non-sealed. The compiler forces you to make the choice; forgetting it is a compile error. (A permitted interface can only be sealed or non-sealed. A permitted record or enum is already implicitly final.)

All permitted subclasses must exist right now. You cannot put future or placeholder types in the permits list. If you write permits Circle, Rectangle, Triangle, then Triangle must be an actual class or interface that exists in the codebase today. You cannot reserve a slot for a future class.

All permitted subclasses must be in the same package (or the same module, if you use modules).

Check yourself ​

  1. What problem do sealed classes and interfaces solve?
  2. sealed interface Shape permits Circle, Rectangle. Can Triangle implements Shape compile?
  3. What three choices must each permitted subclass make, and what does each mean?
  4. Can a class that extends Circle indirectly (through a middle class) be in Shape's permits list?
  5. Can you list a class in permits that you plan to write later?
  6. In the example, can RightTriangle extends Triangle compile? Why?
  7. How is non-sealed spelled in code?
Answers
  1. Uncontrolled inheritance: without them any class can extend or implement your type, and code has to guess about unknown subtypes.
  2. No, Triangle is not in the permits list.
  3. final (no more subclasses), sealed ... permits ... (only the listed subclasses), non-sealed (open to anyone).
  4. No, permitted types must be direct subclasses/implementors.
  5. No, every permitted type must exist now.
  6. Yes, because Triangle is non-sealed.
  7. With a hyphen: non-sealed.