PrepZone Logo
PrepZone

Sealed Classes

Closing a hierarchy on purpose, so the compiler can prove you handled every case.

Why this matters

  • Before sealed types, a public interface could be implemented by anyone, so every switch over its subtypes needed a defensive default that silently swallowed new cases.
  • Sealing plus records plus pattern matching is the combination behind data-oriented programming in modern Java, and the three features were designed together.
  • It makes a design decision explicit in the code: this hierarchy is a closed set of alternatives, not an extension point.

The problem

Java
public interface Shape { }                     // anyone can implement this

double area(Shape shape) {
    if (shape instanceof Circle c) return Math.PI * c.radius() * c.radius();
    if (shape instanceof Square s) return s.side() * s.side();
    throw new IllegalArgumentException("unknown shape: " + shape);   // a runtime landmine
}

A new Triangle compiles fine everywhere and fails at runtime, possibly months later. The compiler cannot help, because it has no idea how many shapes exist.

Sealing the hierarchy

Java
public sealed interface Shape permits Circle, Square, Triangle { }

public record Circle(double radius) implements Shape { }
public record Square(double side) implements Shape { }
public record Triangle(double base, double height) implements Shape { }
sealed interface Shapepermits Circle, Square, Triangle
Circlerecord
Squarerecord
Trianglerecord

Any other type that tries to implement Shape fails to compile — the hierarchy cannot be extended from outside.

Because the permit list is closed, the compiler knows every possible case. That is what lets a switch over a sealed type drop the default branch safely.

Now the compiler knows there are exactly three shapes, and it can prove a switch is complete.

Java
double area(Shape shape) {
    return switch (shape) {                         // no default needed
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Square s -> s.side() * s.side();
        case Triangle t -> 0.5 * t.base() * t.height();
    };
}

Add a fourth shape to the permits clause and this method stops compiling until you handle it. Every switch over Shape across the whole codebase does the same. That is the entire point.

The rules

What the compiler enforces

  • A sealed type must list its direct subtypes in permits, unless they are all in the same file, in which case permits may be omitted.
  • Every permitted subtype must be in the same module, or the same package if there is no module.
  • Every permitted subtype must actually extend or implement the sealed type — a mismatch is an error.
  • Every permitted subtype must choose one of three options: final, sealed (continuing the restriction), or non-sealed (reopening that branch).
  • Records and enums are implicitly final, so they satisfy the requirement with no extra keyword.
Java
// permits omitted — all subtypes live in this file
sealed interface Result {
    record Success(String payload) implements Result { }
    record Failure(String reason, Throwable cause) implements Result { }
}
Java
// Each branch declares its own policy
public sealed class Vehicle permits Car, Truck, Motorcycle { }

public final class Car extends Vehicle { }                    // closed here

public sealed class Truck extends Vehicle permits PickupTruck, SemiTruck { }
public final class PickupTruck extends Truck { }
public final class SemiTruck extends Truck { }

public non-sealed class Motorcycle extends Vehicle { }         // deliberately reopened

Sealed versus the alternatives

AspectSealed hierarchyEnum
InstancesUnbounded — many Circles with different radiiA fixed set of singletons
StateEach subtype carries its own fieldsShared fields across all constants
ExhaustivenessChecked by the compiler in switchChecked by the compiler in switch
Good forAlternative shapes of data — Success or FailureA closed set of named values — Status, Day
  • Instances

    Sealed hierarchyUnbounded — many Circles with different radii
    EnumA fixed set of singletons
  • State

    Sealed hierarchyEach subtype carries its own fields
    EnumShared fields across all constants
  • Exhaustiveness

    Sealed hierarchyChecked by the compiler in switch
    EnumChecked by the compiler in switch
  • Good for

    Sealed hierarchyAlternative shapes of data — Success or Failure
    EnumA closed set of named values — Status, Day

An enum closes the set of values; a sealed type closes the set of shapes.

AspectSealed + switchAbstract method + polymorphism
Adding a subtypeEvery switch breaks until updated — visibleCompiler forces you to implement the method
Adding an operationWrite one new method in one placeMust touch every single subtype
Where logic livesOutside the data, grouped by operationInside each type, grouped by type
Best whenData is stable, operations keep growingOperations are stable, types keep growing
  • Adding a subtype

    Sealed + switchEvery switch breaks until updated — visible
    Abstract method + polymorphismCompiler forces you to implement the method
  • Adding an operation

    Sealed + switchWrite one new method in one place
    Abstract method + polymorphismMust touch every single subtype
  • Where logic lives

    Sealed + switchOutside the data, grouped by operation
    Abstract method + polymorphismInside each type, grouped by type
  • Best when

    Sealed + switchData is stable, operations keep growing
    Abstract method + polymorphismOperations are stable, types keep growing

Neither replaces the other. Choose based on which axis changes more often.

A realistic use

Modelling an operation that can succeed, fail, or still be running:

Java
public sealed interface JobState {
    record Queued(Instant since) implements JobState { }
    record Running(Instant started, int percent) implements JobState { }
    record Completed(Instant finished, String output) implements JobState { }
    record Failed(Instant finished, String reason) implements JobState { }
}
Java
String describe(JobState state) {
    return switch (state) {
        case Queued q -> "waiting since " + q.since();
        case Running r -> r.percent() + "% done";
        case Completed c -> "finished: " + c.output();
        case Failed f -> "failed: " + f.reason();
    };
}

boolean isTerminal(JobState state) {
    return switch (state) {
        case Completed ignored, Failed ignored2 -> true;
        case Queued ignored, Running ignored2 -> false;
    };
}

Compare that to a single mutable Job class with a status enum and four nullable fields, where output is only meaningful for one status and nothing stops you reading it in the others. The sealed version makes invalid combinations unrepresentable.

What it looks like at runtime

Java
Class<?> type = Shape.class;
System.out.println(type.isSealed());                            // true
System.out.println(Arrays.toString(type.getPermittedSubclasses()));

The permitted list is recorded in the class file as a PermittedSubclasses attribute, so the JVM enforces it too. Sealing is not only a compile-time convention — loading an unlisted subtype fails verification.

Common misreadings

  • "Sealed means final." final allows no subtypes; sealed allows a known list.
  • "permits is always required." It can be omitted when every subtype is in the same source file.
  • "Permitted subtypes can live anywhere." They must be in the same module, or the same package without modules.
  • "Sealing forces exhaustiveness everywhere." A non-sealed branch reopens the hierarchy and brings default back.
  • "Sealed types replace enums." An enum closes a set of values; a sealed type closes a set of shapes.
  • "Sealed classes replace polymorphism." They are complementary — choose based on whether types or operations change more.
  • "It is only a compiler check." The permitted list is in the class file and the JVM enforces it.

Quick recall

Everything you need if you only revisit this box.

  • sealed restricts which types may extend or implement, listed in permits — omittable when all subtypes share the file.
  • Every permitted subtype must be final, sealed, or non-sealed, and must live in the same module or package.
  • Records and enums are implicitly final, which is why sealed interface + records is the standard pairing.
  • The payoff is compiler-checked exhaustiveness in switch, so no default is needed and new subtypes break the build until handled.
  • non-sealed reopens a branch and loses exhaustiveness through it.
  • Use a sealed type for alternative shapes of data; use an enum for a closed set of named values.
  • Prefer sealed + switch when operations grow; prefer abstract methods when types grow.
  • The permits list is recorded in the class file and enforced by the JVM.

Test yourself

Answer these before moving on — recall is what makes it stick.