Sealed Classes and Pattern Matching in Java

A sealed type names exactly which types may extend it, which lets the compiler check that a pattern switch covers every case.

Sealed types

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

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 { }

A sealed type restricts which types may extend or implement it. Finalised in Java 17, it fills the gap between final, which allows nothing, and an open type, which allows anything.

ModifierWho may extend
finalNobody
sealedOnly the permitted types
NeitherAnyone

What each permitted subtype must declare

public sealed class Payment permits CardPayment, UpiPayment, WalletPayment { }

public final class CardPayment extends Payment { }           // closed
public sealed class UpiPayment extends Payment
        permits VerifiedUpiPayment { }                        // sealed further
public non-sealed class WalletPayment extends Payment { }     // reopened

Every permitted subtype must be final, sealed or non-sealed. The compiler forces the decision, so the hierarchy is never accidentally left open.

Other rules

  • Permitted subtypes must be in the same package, or the same module if named.
  • The permits clause can be omitted when all subtypes are in the same file.
  • Records are implicitly final, which makes them natural members of a sealed hierarchy.
// permits is inferred here
public sealed interface Result {
    record Success(String value) implements Result { }
    record Failure(String message) implements Result { }
}

Exhaustive pattern switch

static double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t  -> t.base() * t.height() / 2;
    };                                    // no default needed
}
This is the real payoff. Because Shape is sealed, the compiler knows the complete list. Add a fourth shape and every switch that has not been updated fails to compile, pointing straight at the work remaining. A default branch would silence that, which is why you should not add one.

Record patterns

static double area(Shape shape) {
    return switch (shape) {
        case Circle(double radius)               -> Math.PI * radius * radius;
        case Rectangle(double width, double h)   -> width * h;
        case Triangle(double base, double h)     -> base * h / 2;
    };
}

A record pattern destructures the components directly, so no accessor calls are needed. Introduced in Java 21, and it nests to any depth.

sealed interface Node permits Leaf, Branch { }
record Leaf(int value) implements Node { }
record Branch(Node left, Node right) implements Node { }

static int sum(Node node) {
    return switch (node) {
        case Leaf(int value) -> value;
        case Branch(Node left, Node right) -> sum(left) + sum(right);
    };
}

Guards

static String describe(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 100 -> "a very large circle";
        case Circle c                        -> "a circle";
        case Rectangle(double w, double h) when w == h -> "a square";
        case Rectangle r                     -> "a rectangle";
        case Triangle t                      -> "a triangle";
    };
}

A when clause adds a condition to a pattern. Order matters: guarded cases must come before the unguarded case for the same type, or the compiler reports the later one as unreachable.

null

static String describe(Shape shape) {
    return switch (shape) {
        case null        -> "nothing";
        case Circle c    -> "circle";
        case Rectangle r -> "rectangle";
        case Triangle t  -> "triangle";
    };
}

Without an explicit case null, a null selector throws NullPointerException. That default was chosen to match the older behaviour of switch.

A worked example

public sealed interface Command
        permits Publish, Archive, Rename { }

public record Publish(long noteId, LocalDate on) implements Command { }
public record Archive(long noteId, String reason) implements Command { }
public record Rename(long noteId, String title) implements Command { }

public String apply(Command command) {
    return switch (command) {
        case Publish(long id, LocalDate on) when on.isAfter(LocalDate.now()) ->
                "note " + id + " scheduled for " + on;
        case Publish(long id, LocalDate on) ->
                "note " + id + " published on " + on;
        case Archive(long id, String reason) ->
                "note " + id + " archived: " + reason;
        case Rename(long id, String title) ->
                "note " + id + " renamed to " + title;
    };
}

Sealed types compared with polymorphism

Sealed plus pattern switchAbstract method
Adding a subtypeEvery switch fails to compileOnly the new class is written
Adding an operationOne new method in one placeEvery subtype must be edited
Behaviour livesOutside the dataInside each type
SuitsA closed set of data shapesAn open set of behaviours

The two approaches trade the same difficulty in opposite directions. Use polymorphism when new types arrive often, and a sealed hierarchy when new operations arrive often over a fixed set of shapes.

Common mistakes

  • Adding a default to a sealed switch, which discards the exhaustiveness check.
  • Forgetting to mark a permitted subtype final, sealed or non-sealed.
  • Placing a permitted subtype in a different package.
  • Putting an unguarded case before a guarded one for the same type.
  • Forgetting case null when the value can be null.
  • Sealing a hierarchy that genuinely needs to be extensible.

Best practices

  • Seal a hierarchy when the set of alternatives is genuinely closed, such as commands, results or states.
  • Prefer records as the permitted subtypes.
  • Never add default to a sealed switch.
  • Use record patterns to destructure rather than calling accessors.
  • Keep guards simple and ordered from most to least specific.

Practice

  1. Model a payment result as a sealed interface with success and failure records, and handle both in a switch.
  2. Add a fourth shape and observe which switches now fail to compile.
  3. Why is a default branch harmful in a sealed switch?
  4. Write a nested record pattern that reads two levels deep.
  5. Give one example where polymorphism would be the better choice.

Conclusion

Sealing a hierarchy tells the compiler the full list of alternatives, and pattern matching then lets it prove that every one is handled. Together they turn a whole class of missing case bugs into compile errors.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Reflection in Java

Reflection inspects and manipulates classes at runtime. It powers most frameworks and should be rare in application code.

Read more
Java

Dynamic Proxies in Java

A dynamic proxy implements an interface at runtime and routes every call through one handler, which is how cross cutting behaviour is added.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.