Skip to content

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 in Java source code. 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.

The Colocation Rule

There is one practical constraint you need to know: all permitted subclasses must be directly reachable by the sealed class. In the most common case, this means they should be in the same package. If you are using Java modules, they should be in the same module.

The reason for this rule is straightforward. The sealed class needs to know at compile time that all the classes in its permits list actually exist and directly extend or implement it. If you put them in different packages or modules, the compiler cannot verify the relationship properly.

Here is what this looks like in practice:

// Same package: works perfectly
com.example.shapes/
    Shape.java       (sealed interface Shape permits Circle, Rectangle)
    Circle.java      (final class Circle implements Shape)
    Rectangle.java   (sealed class Rectangle implements Shape permits ...)

If you try to put Circle in com.example.shapes.round and keep Shape in com.example.shapes, you will get a compile error. The permits clause is not just a name list; it is a compile time contract that requires all parties to be colocated.

This rule also enforces the idea that the complete shape hierarchy lives in one place. When another developer opens your shapes package, they can read all the files there and immediately understand the complete picture of what shapes exist. There are no hidden implementors scattered across the codebase.

A Complete Working Example

Let us build the full hierarchy to see all three options working together. The hierarchy here mirrors the example from the lesson: 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.

Sealed Classes and Pattern Matching: The Real Power

Sealed classes become dramatically more powerful when you combine them with pattern matching in switch expressions. This combination arrived alongside sealed classes in Java 17 and matured further in Java 21.

Remember the problem from the beginning: you always needed a default case in your switch statements because unknown subtypes might arrive. With sealed classes, the compiler knows the complete set of possible types. That means it can enforce exhaustive coverage. You no longer need a default case, and if you miss a type, you get a compile error instead of a runtime surprise.

java
// With sealed Shape, the compiler knows all possible types
double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> 0.5 * t.base() * t.height();
        // No default needed! The compiler knows these are all the cases
        // because Shape is sealed with only these types permitted
    };
}

If you add a new permitted type to your sealed hierarchy and forget to update this switch, the compiler tells you immediately. That is the kind of safety net that prevents entire categories of bugs.

Compare this to the old approach where you always wrote a defensive default. The default was hiding the fact that the switch was not really exhaustive. You were papering over a gap in your type system. Sealed classes close that gap permanently.

This combination is actually one of the main design motivations behind sealed classes in Java. The language designers wanted algebraic data types similar to what you see in Haskell, Scala, or Kotlin. A sealed hierarchy is a closed set of variants, and exhaustive switch over that set is how you process them safely.

The Rules at a Glance

Let these rules sink in before the interview section. Every one of them matters.

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. There is no implicit default. The compiler forces you to make the choice. Forgetting to add one of these three to a permitted subclass is a compile error.

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 module. This is the colocation requirement. The sealed type and its permitted subtypes must live together so the compiler can verify the relationship at compile time.

Interview Questions

What problem do sealed classes solve?

Sealed classes solve the problem of uncontrolled inheritance. Before sealed classes, any class or interface in Java could be extended or implemented by any other class in the same or different package, giving library designers and API authors no way to restrict the type hierarchy. This meant code that handled instances of a type always had to account for unknown subtypes it had never seen. Sealed classes let you declare exactly which types are permitted to extend or implement yours, giving you full control over the hierarchy.

What is the permits clause?

The permits clause is used alongside the sealed keyword to list every class or interface that is allowed to directly extend or implement the sealed type. Only types named in the permits clause can be direct subtypes. Anyone else trying to extend or implement the sealed type gets a compile error.

What are the three options a subclass of a sealed class must choose?

A subclass or implementing class of a sealed type must choose exactly one of:

final: No further subclasses are allowed. This branch of the hierarchy ends here.

sealed with its own permits: The class is itself sealed, restricting which classes can extend it further. You are continuing the controlled hierarchy deeper.

non sealed: The class is opened back up to unrestricted extension. Any number of unknown classes can now extend or implement this type freely. This is an intentional choice to open a branch, not an accident.

Can a sealed class have abstract subclasses?

Yes. You can have an abstract sealed class that is permitted by a sealed interface, and that abstract class can have its own permits list of concrete classes. Abstract classes fit naturally into sealed hierarchies and are often used to share behavior across a controlled group of subtypes.

What is the colocation rule for sealed classes?

All classes and interfaces listed in the permits clause must be in the same package as the sealed type, or in the same module if you are using the Java module system. This ensures the compiler can verify that the permitted types actually exist and directly extend or implement the sealed type.

How do sealed classes work with pattern matching?

When you use a sealed type in a switch expression with pattern matching, the compiler knows the complete set of permitted types. This allows the compiler to check that your switch is exhaustive, meaning it handles every possible type. If it is exhaustive, you do not need a default case. If you add a new permitted type and forget to add a case for it in the switch, you get a compile error immediately rather than silent runtime failures.

Can an interface be sealed?

Yes. The sealed keyword works on both classes and interfaces. A sealed interface uses permits exactly like a sealed class. Types in the permits list may implement the interface or extend it if they are themselves interfaces, and they must still choose between final, sealed, or non sealed.

What happens if you add a class to the permits list that does not yet exist?

You get a compile error. The sealed class specification requires that all types in the permits list actually exist and directly extend or implement the sealed type. You cannot reserve a slot for a future class.

What is the difference between the non sealed modifier and simply not using sealed at all?

A type marked non sealed is part of a sealed hierarchy. It explicitly opts out of restriction at its level, while still being a known and permitted type in the hierarchy above it. A type that is not using sealed at all is outside any sealed hierarchy entirely. The distinction matters for pattern matching exhaustiveness: when the compiler sees a sealed type in a switch, it knows the complete list of direct subtypes. The non sealed option is accounted for as one known branch even though its own subtypes are not enumerable.

Can a class marked non sealed be extended by any class?

Yes. Once a class is declared non sealed, the restriction from the sealed hierarchy above does not propagate. Any class can extend a non sealed class without being named in any permits list. This is the intended behavior when you want to open a branch of a controlled hierarchy.

When would you use the non sealed modifier?

You use non sealed when you want to define a controlled hierarchy at the top level but acknowledge that at some point in the tree you need extensibility. For example, a payment system might seal PaymentMethod to allow only OnlinePayment and OfflinePayment, but then mark OnlinePayment as non sealed because there are many online payment providers and you cannot enumerate them all upfront.

What happens if a permitted subclass does not choose any of the three modifiers?

The compiler rejects it. Every class or interface that appears in a permits list must explicitly declare final, sealed, or non sealed. There is no implicit default. This is by design; the goal of sealed classes is to make every inheritance decision explicit.

Can records be permitted types in a sealed hierarchy?

Yes. Records in Java are implicitly final, so they satisfy the requirement to choose a modifier. You can include records in a permits list, and they will work correctly as permitted types that cannot be further extended.

Putting It All Together

Sealed classes and interfaces give you a tool that was simply missing from Java before version 17. Every class hierarchy you design has an intended structure. Before sealed classes, expressing that structure required documentation and conventions that could be violated at any time. Now you can express it directly in the code, and the compiler enforces it.

The three options cover every case you might need. You can stop a branch with final, continue restricting it with sealed, or deliberately open it back up with non sealed. The colocation rule ensures the hierarchy is cohesive and easy to understand. The combination with pattern matching switch expressions turns sealed hierarchies into a powerful, type safe way to write exhaustive code that processes your domain types without defensive defaults.

When you design a type hierarchy and you know exactly what types belong in it, reach for sealed. Your future self and your teammates will thank you for the clarity, and the compiler will back you up every step of the way.