Enums, Records & Sealed Types

Sealed Classes

15 min Lesson 7 of 13

Sealed Classes

Java's type system has always let any class extend any other class — unless you mark it final. But final is an all-or-nothing switch: either nobody can extend you, or everyone can. Sealed classes, introduced as a standard feature in Java 17, give you a middle ground: you decide precisely which classes are allowed to extend (or implement) yours. The compiler enforces that list, and nothing else can join the hierarchy.

The Problem Sealed Classes Solve

Imagine you are modeling the result of a payment operation. You want exactly three outcomes: Success, Failure, and Pending. With plain inheritance, any external code could add a fourth subclass, silently breaking your exhaustive switch logic. Sealed classes make the hierarchy closed and known at compile time, so the compiler can verify that you have handled every case.

Syntax: sealed and permits

Declare a sealed class with the sealed modifier and list every permitted subclass in a permits clause:

public sealed class PaymentResult permits Success, Failure, Pending { // shared state or methods go here }

Each permitted subclass must then declare one of three modifiers:

  • final — the subclass itself cannot be extended further.
  • sealed — the subclass is also sealed and must provide its own permits list (allows a deeper restricted hierarchy).
  • non-sealed — the subclass reopens the hierarchy; anyone can extend it freely.
All three permitted subclasses must pick one of those modifiers. Forgetting to add it is a compile error, which is exactly the point — the compiler prevents accidental open extension.

Here is the full example with all three outcomes as final records (records are a natural fit — more on that in the next lesson):

public sealed class PaymentResult permits Success, Failure, Pending {} public final class Success extends PaymentResult { private final String transactionId; public Success(String transactionId) { this.transactionId = transactionId; } public String transactionId() { return transactionId; } } public final class Failure extends PaymentResult { private final String reason; public Failure(String reason) { this.reason = reason; } public String reason() { return reason; } } public final class Pending extends PaymentResult { private final long estimatedMs; public Pending(long estimatedMs) { this.estimatedMs = estimatedMs; } public long estimatedMs() { return estimatedMs; } }

Using the Sealed Hierarchy in a switch

The real payoff comes with pattern matching for switch (covered in detail in Lesson 9). Because the compiler knows every permitted subtype, it can check that your switch is exhaustive — no default branch needed:

static String describe(PaymentResult result) { return switch (result) { case Success s -> "Paid. Transaction: " + s.transactionId(); case Failure f -> "Failed: " + f.reason(); case Pending p -> "Pending, ~" + p.estimatedMs() + "ms"; }; }

If you later add a fourth permitted class, the compiler immediately flags every switch that does not handle it. This is algebraic data type safety in Java.

Sealed classes and pattern-matching switch are designed together. Get comfortable defining closed hierarchies now; the switch pattern matching in Lesson 9 will make them even more powerful.

Nesting: sealed Subclasses

A permitted subclass may itself be sealed, letting you build a two-level restricted tree:

public sealed class Shape permits Circle, Polygon {} public final class Circle extends Shape { private final double radius; public Circle(double radius) { this.radius = radius; } public double radius() { return radius; } } // Polygon is sealed — only Triangle and Rectangle can extend it public sealed class Polygon extends Shape permits Triangle, Rectangle {} public final class Triangle extends Polygon {} public final class Rectangle extends Polygon {}

The full set of concrete types the compiler sees is: Circle, Triangle, Rectangle. A switch on Shape must handle all three.

non-sealed: Reopening the Hierarchy

Sometimes you want to restrict most of a hierarchy but allow one branch to be freely extended by third parties. Mark that branch non-sealed:

public sealed class Notification permits EmailNotification, SmsNotification, CustomNotification {} public final class EmailNotification extends Notification {} public final class SmsNotification extends Notification {} // Anyone can subclass CustomNotification — the hierarchy is open from here public non-sealed class CustomNotification extends Notification {}
Using non-sealed removes exhaustiveness guarantees for that branch. The compiler can no longer verify that a switch handles every possible CustomNotification subtype. Only use it when open extension is intentional.

File and Package Rules

There is one placement rule to know: each permitted subclass must be in the same package (or in the same compilation unit) as the sealed parent. If they are in separate files, they all go in the same package — you cannot permit a class from another package. This is intentional: sealed hierarchies are meant to represent a closed, co-owned set of types.

Summary

Sealed classes let you declare a fixed, compiler-enforced set of subtypes. Use sealed … permits … on the parent, then mark each permitted child final, sealed, or non-sealed. The hierarchy is documented, versioned, and exhaustively checkable — especially valuable when combined with pattern-matching switch. In the next lesson, you will see how sealed interfaces pair with records to produce concise, safe algebraic data types.