Java 17 finalised sealed classes (JEP 409), a language feature that gives you fine‑grained control over inheritance. With sealed classes, you can declare a class or interface and explicitly list which types are allowed to extend or implement it. This fills the gap between final (no subclasses) and completely open inheritance (any subclass allowed).


The Inheritance Dilemma

Before sealed classes, Java developers faced a choice:

  • final classes – too restrictive; you cannot extend them at all.
  • Open classes – anyone can extend them, which makes it hard to reason about a fixed set of subtypes.

Sealed classes introduce a third option: “only these specific classes may extend or implement this type.” This is invaluable when modelling a closed domain, such as:

  • Shapes in a geometry library (Circle, Rectangle, Triangle)
  • Payment statuses (Success, Failure, Pending)
  • AST nodes in a compiler (IfNode, WhileNode, AssignmentNode)
  • JSON value types (JsonObject, JsonArray, JsonString, etc.)

Key Keywords and Their Roles

Understanding the interplay of sealed, permits, final, and non-sealed is essential:

KeywordUsed OnMeaning
sealedClass or interfaceRestricts which types may extend/implement it; must be followed by permits (unless subclasses are nested).
permitsAfter sealedLists the permitted direct subclasses or implementors.
finalSubclassCannot be extended further – the hierarchy ends here.
sealedSubclassIs itself sealed and can have its own permitted subclasses (nested sealed hierarchy).
non-sealedSubclassRe‑opens the subclass for arbitrary extension – the sealed constraint stops here.

Basic Example: Sealed Class with permits

Let's define a sealed Shape class that allows only three specific subclasses:

public abstract sealed class Shape permits Circle, Rectangle, Triangle {
    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, 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, height;
    public Triangle(double base, double height) { this.base = base; this.height = height; }

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

Any attempt to create a fourth subclass (e.g., Hexagon) will be rejected by the compiler – the hierarchy is closed.


Omitting permits with Nested Subclasses

If all permitted subclasses are defined as nested classes inside the sealed class, you can omit the permits clause:

public abstract sealed class Operation {
    public abstract int apply(int a, int b);

    public static final class Add extends Operation {
        @Override public int apply(int a, int b) { return a + b; }
    }

    public static final class Sub extends Operation {
        @Override public int apply(int a, int b) { return a - b; }
    }

    public static final class Mul extends Operation {
        @Override public int apply(int a, int b) { return a * b; }
    }
}

This keeps the code self‑contained and clearly communicates that these are the only operations.


Sealed Interfaces and Records

Sealed interfaces work equally well and pair beautifully with records (which are implicitly final):

public sealed interface PaymentResult permits Success, Failure, Pending { }

public record Success(String transactionId) implements PaymentResult { }
public record Failure(String reason)        implements PaymentResult { }
public record Pending(String reference)     implements PaymentResult { }

Records are a natural choice here because each variant holds different data, and they are inherently immutable.


Pattern Matching with switch (Java 21+)

The real power of sealed classes shines when combined with pattern matching in switch expressions. Because the compiler knows all permitted subtypes, it can verify exhaustiveness – no default case is needed:

double describeArea(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 – compiler ensures all cases are covered
    };
}

If you later add a new permitted subclass (say Hexagon) to Shape, every switch over Shape that lacks a Hexagon case will become a compile‑time error – catching the gap immediately.


Reopening a Branch with non-sealed

Sometimes you want to allow arbitrary extension for some part of the hierarchy. Mark a permitted subclass as non-sealed to reopen it:

public abstract sealed class Vehicle permits Car, Truck, SpecialVehicle { }

public final class Car extends Vehicle { }
public final class Truck extends Vehicle { }
public non-sealed class SpecialVehicle extends Vehicle { }

// Now anyone can extend SpecialVehicle:
public class Ambulance extends SpecialVehicle { }
public class FireTruck  extends SpecialVehicle { }

This gives you a hybrid model: a closed top‑level family, with an open branch for extensibility where needed.


Important Rules and Constraints

  • The sealed class and all its permitted subclasses must be in the same package or in the same module.
  • Every permitted subclass must directly extend the sealed class (not a subclass of it).
  • Each permitted subclass must be marked final, sealed, or non-sealed.
  • If the sealed class is an interface, the implementors must be final, sealed, or non-sealed classes (records are implicitly final).
  • You cannot use a sealed class with enum – enums are already effectively a closed set of constants.

When to Use Sealed Classes vs Enums

FeatureSealed ClassesEnums
Number of instances Many (unlimited) – each subclass can have many objects Fixed set of singleton constants
State Each subclass can have its own fields and behaviour Enums can have fields, but they are effectively singletons
Pattern matching Excellent – can deconstruct and handle different shapes Limited to constant matching (switch on enum constants)
Use case Modelling complex, state‑ful variants (e.g., AST nodes, JSON types) Modelling fixed sets of named values (e.g., days of the week, status codes)

Real‑World Use Cases

1. State Machines

public sealed interface State permits Idle, Running, Paused, Stopped { }

public record Idle() implements State { }
public record Running(long startTime) implements State { }
public record Paused(long elapsed) implements State { }
public record Stopped() implements State { }

2. Abstract Syntax Tree Nodes

public sealed interface Expr permits Const, Add, Mul, Var { }

public record Const(int value) implements Expr { }
public record Add(Expr left, Expr right) implements Expr { }
public record Mul(Expr left, Expr right) implements Expr { }
public record Var(String name) implements Expr { }

3. JSON Data Types

public sealed interface JsonValue permits JsonObject, JsonArray, JsonString, JsonNumber, JsonBoolean, JsonNull { }

public record JsonObject(Map<String, JsonValue> members) implements JsonValue { }
public record JsonArray(List<JsonValue> elements) implements JsonValue { }
public record JsonString(String value) implements JsonValue { }
// ... and so on

4. Payment Processing Results

We already saw the PaymentResult example – it's a perfect fit for sealed interfaces with records.


Best Practices

  • Prefer sealed interfaces with records for data‑oriented hierarchies – they are concise and immutable.
  • Use sealed classes when you need shared state or behaviour that cannot be captured by records (e.g., mutable fields, helper methods).
  • Design for extensibility – if you anticipate that third‑party developers will need to extend a branch, mark that branch non-sealed.
  • Combine with pattern matching to write safe, readable, and maintainable code that the compiler checks for completeness.
  • Keep the permitted list small – a sealed hierarchy with more than 5–7 subtypes may indicate a design that could be simplified.

Summary

Sealed classes give you a powerful tool to declare closed type hierarchies while retaining the flexibility of object‑oriented design. They turn runtime errors into compile‑time checks, especially when used with pattern matching. Mark your root type with sealed, list your permitted subtypes, and choose final, sealed, or non-sealed for each one – depending on how open that branch should be.

Whether you are modelling a domain, building an interpreter, or handling responses from an external API, sealed classes help you express your intent clearly and let the compiler enforce your design decisions. Start using them today – they are available in Java 17 and later.


Happy coding!