What are sealed classes in Java and how do they work?

Java Sealed Classes Interview Question

1. Short Answer

A sealed class (Java 17, JEP 409) explicitly controls which classes are allowed to extend it using a permits clause. Unlike a final class (no subclasses at all), a sealed class allows a controlled, finite set of subclasses while preventing arbitrary external extension. Every permitted subclass must declare itself as final, sealed, or non-sealed.

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

public final class Circle    extends Shape {}
public final class Rectangle extends Shape {}
public final class Triangle  extends Shape {}

// Compile error — Pentagon is not in the permits list
// public class Pentagon extends Shape {}

2. The Problem Sealed Classes Solve

Before sealed classes, Java gave you two options for controlling inheritance:

  • final — completely blocks subclassing. Too restrictive when you need some known subtypes.
  • Open class — any class anywhere can extend it. Too permissive when the hierarchy should be closed.

Sealed classes provide a middle ground: you define a closed hierarchy of known types, enabling the compiler to reason about exhaustiveness and making domain modeling safer.

Algebraic Data Types

Sealed classes are Java's answer to algebraic sum types (also called discriminated unions), common in languages like Haskell, Rust (enum), and Kotlin (sealed class). They let you express "a Shape is exactly one of: Circle, Rectangle, or Triangle."

3. Syntax — sealed, permits, Subclass Modifiers

Every class in the sealed hierarchy must explicitly declare one of three continuation strategies:

Modifier on subclass Meaning
final Cannot be extended further. Hierarchy ends here.
sealed Is itself sealed with its own permits clause — hierarchy continues in a controlled way.
non-sealed Reopens the hierarchy — any class may extend this subclass freely.
// Sealed class with three variants
public sealed class Notification
    permits EmailNotification, SmsNotification, PushNotification {}

// Final — no further extension
public final class EmailNotification extends Notification {
    private final String recipient;
    // ...
}

// Also sealed — controls its own subtypes
public sealed class SmsNotification extends Notification
    permits RegionalSms, InternationalSms {}

public final class RegionalSms     extends SmsNotification {}
public final class InternationalSms extends SmsNotification {}

// Non-sealed — opens up the hierarchy at this point
public non-sealed class PushNotification extends Notification {
    // Anyone can extend PushNotification
}

4. Shape Hierarchy — Complete Code Example

// File: Shape.java
public sealed interface Shape permits Circle, Rectangle, Triangle {
    double area();
}

// File: Circle.java
public record Circle(double radius) implements Shape {
    public double area() {
        return Math.PI * radius * radius;
    }
}

// File: Rectangle.java
public record Rectangle(double width, double height) implements Shape {
    public double area() {
        return width * height;
    }
}

// File: Triangle.java
public record Triangle(double base, double height) implements Shape {
    public double area() {
        return 0.5 * base * height;
    }
}

// Usage
public class ShapeDemo {
    public static void printArea(Shape shape) {
        // Pattern matching switch — exhaustive because Shape is sealed
        String result = switch (shape) {
            case Circle c    -> "Circle area: %.2f".formatted(c.area());
            case Rectangle r -> "Rectangle area: %.2f".formatted(r.area());
            case Triangle t  -> "Triangle area: %.2f".formatted(t.area());
            // No default needed — compiler verifies exhaustiveness
        };
        System.out.println(result);
    }

    public static void main(String[] args) {
        printArea(new Circle(5));
        printArea(new Rectangle(4, 6));
        printArea(new Triangle(3, 8));
    }
}
/* Output:
   Circle area: 78.54
   Rectangle area: 24.00
   Triangle area: 12.00
*/
Records + Sealed Interfaces

Combining sealed interfaces with records is a powerful pattern for domain modeling. The sealed interface defines the closed hierarchy; records provide the concise, immutable implementations. This combination is Java's idiomatic equivalent of Kotlin sealed data classes.

5. Sealed Interfaces

Interfaces can also be sealed. This is often preferred over sealed abstract classes because it allows permitted types to be records (which cannot extend a class).

// Sealed interface for a payment result type
public sealed interface PaymentResult
    permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {

    record Success(String transactionId, double amount) implements PaymentResult {}
    record Failure(String errorCode, String message)   implements PaymentResult {}
    record Pending(String referenceId)                  implements PaymentResult {}
}

// Switch expression over a sealed interface
void handleResult(PaymentResult result) {
    switch (result) {
        case PaymentResult.Success s ->
            System.out.println("Paid: " + s.amount() + ", txn=" + s.transactionId());
        case PaymentResult.Failure f ->
            System.err.println("Failed: " + f.errorCode() + " — " + f.message());
        case PaymentResult.Pending p ->
            System.out.println("Pending: ref=" + p.referenceId());
    }
}

6. Integration with Pattern Matching Switch (Java 21)

The compiler checks that a switch over a sealed type covers all permitted subtypes. If you add a new permitted subtype later and forget to update the switch, you get a compile error, not a runtime bug.

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

int evaluate(Expr expr) {
    return switch (expr) {
        case Num(int v)         -> v;
        case Add(Expr l, Expr r) -> evaluate(l) + evaluate(r);
        case Mul(Expr l, Expr r) -> evaluate(l) * evaluate(r);
        // Exhaustive — no default needed
    };
}

// evaluate(new Add(new Num(3), new Mul(new Num(2), new Num(4)))) == 11
Record Patterns (Java 21)

Java 21 introduced record patterns — the Num(int v) syntax above deconstructs the record and binds its component in one step. This requires both sealed interfaces/classes and records to work most ergonomically.

7. When to Use Sealed Classes

  • Closed domain hierarchies — e.g., payment states (Pending, Authorized, Settled, Failed), geometric shapes, AST nodes
  • Error/result types — model results as sealed interface Result permits Success, Error instead of checked exceptions or Optional
  • Replacing enums with data — when each variant needs to carry different data (enums can't have per-variant fields)
  • Pattern-matched APIs — when callers need to handle every case and the compiler should enforce completeness

8. Interview Context

Interviewers ask about sealed classes to assess awareness of modern Java. Cover these points:

  • Introduced in Java 17 (JEP 409), previewed in 15 and 16
  • The three subclass modifiers: final, sealed, non-sealed
  • Both classes and interfaces can be sealed
  • Works with pattern matching switch for compiler-verified exhaustiveness
  • Natural companion to records (sealed interface + record variants)
  • Analogous to Kotlin sealed classes, Rust enums, Haskell ADTs

9. Common Pitfalls

  • Forgetting the subclass modifier — every permitted subclass MUST declare final, sealed, or non-sealed. Missing it is a compile error.
  • Permitted classes in different packages (pre-Java 21) — in Java 17, permitted subclasses must be in the same package (or module) as the sealed class. Java 21 relaxes this within a module.
  • Using sealed for everything — sealed is for intentionally closed hierarchies. Open hierarchies (e.g., extension points) should stay as normal classes.
  • Confusing sealed with finalfinal prevents ALL subclassing; sealed allows a named list of subtypes.

10. Conclusion

Sealed classes bring closed-hierarchy type modeling to Java, filling the gap between completely open inheritance and completely closed final classes. They are most powerful when combined with records (for concise value-typed variants) and pattern matching switch (for exhaustiveness checking). Use them wherever you have a finite, known set of types that make up a concept — and let the compiler enforce that all cases are handled.