Part 7 of 14

Sealed Classes (JEP 409): Controlled, Exhaustive Class Hierarchies

Finalized in Java 17 (JEP 409). This is the headline language feature of the Java 17 LTS release. Previous previews: Java 15 (JEP 360) and Java 16 (JEP 397).

The Problem: Open Hierarchies Are Hard to Reason About

In Java, any class can be extended by default. This openness is flexible but has costs.

Consider a Shape interface used in a drawing application:

// Java 11 — anyone can implement Shape
public interface Shape {
    double area();
}

At runtime, a Shape instance could be a Circle, a Rectangle, a Triangle, or anything else in the classpath. This creates two problems:

Problem 1: The dispatch cannot be verified. If you write a rendering method that handles Circle and Rectangle but not Triangle, there is no compile-time warning. Your code silently fails when a Triangle arrives.

Problem 2: The hierarchy cannot be documented precisely. A reader of your API cannot know the intended subtypes. The JavaDoc comment “Known implementing classes: Circle, Rectangle” is informal and easily out of date.

Sealed classes solve both by making the permitted subtypes part of the type declaration itself — visible to readers and enforced by the compiler.


Sealed Classes: The Solution

Sealing an Interface

public sealed interface Shape permits Circle, Rectangle, Triangle {}

The permits clause lists every class that can implement Shape. Any other class attempting to implement Shape gets a compile error.

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

// This fails to compile:
public class Pentagon implements Shape {} // error: not permitted

The permits Clause

The permitted subclasses must meet one requirement: they must be in the same compilation unit (same .java file) or the same package (with Java 17) or the same module.

In practice, for most projects: the sealed class and its permitted subclasses must be in the same package.

com.example.shapes/
  Shape.java       ← sealed interface
  Circle.java      ← implements Shape
  Rectangle.java   ← implements Shape
  Triangle.java    ← implements Shape

Implicit permits

If all permitted subclasses are in the same .java file, permits can be omitted — Java infers the permitted set:

// Shape.java — all in one file
public sealed interface Shape {}

record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
record Triangle(double base, double height) implements Shape {}

Permitted Subtype Modifiers

Every permitted subclass must be one of:

ModifierMeaning
finalCannot be subclassed further
sealedCan be subclassed, but only by its own permits list
non-sealedCan be subclassed by anyone (re-opens the hierarchy)
public sealed interface Expr permits Literal, BinaryOp, UnaryOp {}

public record Literal(int value) implements Expr {}  // implicitly final (records are final)

public sealed interface BinaryOp extends Expr permits Add, Multiply, Divide {}
public record Add(Expr left, Expr right) implements BinaryOp {}
public record Multiply(Expr left, Expr right) implements BinaryOp {}
public record Divide(Expr left, Expr right) implements BinaryOp {}

public non-sealed interface UnaryOp extends Expr {}  // re-opened: anyone can implement
public record Negate(Expr expr) implements UnaryOp {}
public class CustomOp implements UnaryOp {}  // allowed because UnaryOp is non-sealed

non-sealed is an escape hatch. Use it when you want to seal most of a hierarchy but allow extension at one point.


Sealed Classes (Not Just Interfaces)

sealed works on classes too:

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

public final class Car extends Vehicle {
    private final int seats;
    public Car(int seats) { this.seats = seats; }
}

public final class Truck extends Vehicle {
    private final double payload;
    public Truck(double payload) { this.payload = payload; }
}

public final class Motorcycle extends Vehicle {}

Sealed abstract classes are useful when you need protected constructors or shared state across the hierarchy.


Exhaustiveness: The Key Benefit

The compiler uses sealed type information to verify switch exhaustiveness. This is the primary reason sealed classes exist.

In Switch Expressions (Java 17)

Switch expressions on sealed interfaces are exhaustive without default:

sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double b, double h) implements Shape {}

// Java 17 with --enable-preview (pattern switch is preview in Java 17)
double area = switch (shape) {
    case Circle c    -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> r.w() * r.h();
    case Triangle t  -> 0.5 * t.b() * t.h();
    // No default needed — compiler knows all permitted subtypes
};

Without sealing, you would need default and could silently miss a subtype.

Adding a Subtype Forces Updates

If you add Pentagon to Shape’s permits list, every switch expression on Shape without a default will fail to compile until it handles Pentagon. This is a compile-time safety net that is impossible without sealed classes.


Sealed Classes + Records: Algebraic Data Types

The combination of sealed interfaces and records creates algebraic data types (ADTs) — a pattern from functional programming that is now idiomatic in Java.

Sum Types (Sealed)

A sealed interface is a sum type: a value is exactly one of the permitted subtypes.

sealed interface Result<T> permits Result.Ok, Result.Err {
    record Ok<T>(T value) implements Result<T> {}
    record Err<T>(String message) implements Result<T> {}
}

Product Types (Records)

A record is a product type: a value contains all its components together.

record Address(String street, String city, String country) {}

Combined: An Expression Language

sealed interface Expr permits Num, Add, Mul, Neg {
    record Num(int value)             implements Expr {}
    record Add(Expr left, Expr right) implements Expr {}
    record Mul(Expr left, Expr right) implements Expr {}
    record Neg(Expr expr)             implements Expr {}
}

static int eval(Expr e) {
    return switch (e) {  // --enable-preview in Java 17
        case Expr.Num n -> n.value();
        case Expr.Add a -> eval(a.left()) + eval(a.right());
        case Expr.Mul m -> eval(m.left()) * eval(m.right());
        case Expr.Neg n -> -eval(n.expr());
    };
}

// eval(Add(Num(2), Mul(Num(3), Num(4)))) = 2 + (3 * 4) = 14

This is how Haskell, Scala, Kotlin, and Rust express algebraic data types — now available in Java.


Real-World Example: HTTP Response Type

sealed interface ApiResponse<T> permits ApiResponse.Success, ApiResponse.Error, ApiResponse.Redirect {
    record Success<T>(T body, int statusCode) implements ApiResponse<T> {}
    record Error<T>(String message, int statusCode) implements ApiResponse<T> {}
    record Redirect<T>(String location) implements ApiResponse<T> {}
}

<T> void handle(ApiResponse<T> response) {
    // In Java 17 with instanceof pattern:
    if (response instanceof ApiResponse.Success<T> s) {
        System.out.println("Success: " + s.body());
    } else if (response instanceof ApiResponse.Error<T> e) {
        System.err.println("Error " + e.statusCode() + ": " + e.message());
    } else if (response instanceof ApiResponse.Redirect<T> r) {
        System.out.println("Redirect to: " + r.location());
    }
}

Real-World Example: Domain Events

sealed interface OrderEvent permits OrderEvent.Placed, OrderEvent.Shipped, OrderEvent.Cancelled {
    record Placed(String orderId, List<String> items, Instant timestamp) implements OrderEvent {}
    record Shipped(String orderId, String trackingNumber, Instant timestamp) implements OrderEvent {}
    record Cancelled(String orderId, String reason, Instant timestamp) implements OrderEvent {}
}

String describeEvent(OrderEvent event) {
    return switch (event) {
        // Pattern switch preview in Java 17; final in Java 21
        case OrderEvent.Placed p    -> "Order " + p.orderId() + " placed with " + p.items().size() + " items";
        case OrderEvent.Shipped s   -> "Order " + s.orderId() + " shipped, tracking: " + s.trackingNumber();
        case OrderEvent.Cancelled c -> "Order " + c.orderId() + " cancelled: " + c.reason();
    };
}

Reflection and Sealed Classes

The reflection API exposes sealed class information:

Class<Shape> shapeClass = Shape.class;

System.out.println(shapeClass.isSealed()); // true

Class<?>[] permitted = shapeClass.getPermittedSubclasses();
for (Class<?> subclass : permitted) {
    System.out.println(subclass.getSimpleName()); // Circle, Rectangle, Triangle
}

This is useful for frameworks that need to dynamically discover all subtypes — for example, a JSON deserializer that needs to map a discriminator field to a concrete type.


Sealed Interfaces in Libraries

Sealed classes in library APIs allow the library to guarantee stability without preventing users from using the full type:

// Library code
public sealed interface DatabaseResult permits DatabaseResult.Rows, DatabaseResult.UpdateCount, DatabaseResult.Error {}

Users of the library can write exhaustive switch expressions on DatabaseResult today, and will get a compile error when the library adds new subtypes — giving them an explicit upgrade task instead of a silent runtime failure.


Common Mistakes

Mistake 1: Permitted Subclass in Different Package

// com.example.shapes/Shape.java
package com.example.shapes;
public sealed interface Shape permits com.example.other.Circle {} // error in Java 17

// Java 17 requires permitted subclasses to be in the same package

Note: Java 21 (with modules) allows cross-package sealed hierarchies within the same module.

Mistake 2: Forgetting final, sealed, or non-sealed

// Error: permitted subclass must be final, sealed, or non-sealed
public sealed interface Shape permits Circle {}
public class Circle implements Shape {}  // compile error: missing modifier

Every permitted subclass must explicitly declare one of the three modifiers.

Mistake 3: non-sealed Without Purpose

non-sealed re-opens the hierarchy at that point. Only use it when external extension at that branch is genuinely needed. Overusing it defeats the purpose of sealing.


Java 17 vs Java 21 Summary

FeatureJava 17Java 21
sealed keywordFinalFinal
permits clauseFinalFinal
Pattern switch on sealedPreview (JEP 406)Final (JEP 441)
Record patterns in switchNoFinal (JEP 440)
Cross-module sealedLimitedImproved

In Java 17, sealed classes are final and production-ready. The full payoff — exhaustive pattern switch — requires either --enable-preview in Java 17 or upgrading to Java 21.


Summary

ConceptSyntax
Declare sealedpublic sealed interface Shape permits Circle, Rectangle {}
Final permittedpublic final class Circle implements Shape {}
Sealed permittedpublic sealed interface BinaryOp extends Expr permits Add, Mul {}
Non-sealed permittedpublic non-sealed class Open implements Shape {}
Implicit permitsAll subtypes in same file — omit permits
ReflectionShape.class.isSealed(), Shape.class.getPermittedSubclasses()
ExhaustivenessCompiler-enforced in switch expressions (preview in Java 17, final in Java 21)

What’s Next

Article 8: Pattern Matching for switch (JEP 406, Preview) covers the preview feature that combines sealed classes with switch expressions — type dispatch without the instanceof chain.