Pattern Matching and Sealed Hierarchies

Article · 14:00

Java spent twenty years arriving at this: instanceof with a binding, a switch over a sealed type that the compiler proves exhaustive, record patterns that destructure, and guards. Together they replace a chain of casts with something that cannot miss a case.

Pattern Matching and Sealed Hierarchies

Java spent twenty years writing this:

public class OldStyle {
    sealed interface Shape permits Square, Circle { }
    record Square(double side) implements Shape { }
    record Circle(double radius) implements Shape { }

    static double area(Object value) {
        if (value instanceof Square) {
            Square square = (Square) value;          // test, then cast, then use
            return square.side() * square.side();
        } else if (value instanceof Circle) {
            Circle circle = (Circle) value;
            return Math.PI * circle.radius() * circle.radius();
        }
        throw new IllegalArgumentException("not a shape: " + value);
    }

    public static void main(String[] args) {
        System.out.println(area(new Square(2)));
        System.out.println(area(new Circle(1)));
    }
}

The test and the cast name the same type twice, nothing checks that every case is covered, and the failure for a new shape arrives at runtime.

instanceof with a pattern

public class InstanceOf {
    public static void main(String[] args) {
        for (Object value : new Object[]{"hello", 42, 3.5, java.util.List.of(1, 2)}) {
            System.out.println(describe(value));
        }
    }

    static String describe(Object value) {
        // The variable is declared by the test, and only exists where the test
        // passed. No cast, and no way to use it in the wrong branch.
        if (value instanceof String s && s.length() > 3) {
            return "a long string: " + s.toUpperCase();
        }
        if (value instanceof String s) {
            return "a short string: " + s;
        }
        if (value instanceof Integer i) {
            return "an int, doubled: " + (i * 2);
        }
        return "something else: " + value;
    }
}

switch over a sealed hierarchy, checked for exhaustiveness

public class Sealed {
    sealed interface Shape permits Square, Circle, Rectangle { }
    record Square(double side) implements Shape { }
    record Circle(double radius) implements Shape { }
    record Rectangle(double width, double height) implements Shape { }

    // No default branch. The compiler knows the permitted subtypes, so it can
    // tell that every one is handled - and will refuse to compile this method
    // the day a fourth Shape is added.
    static double area(Shape shape) {
        return switch (shape) {
            case Square s -> s.side() * s.side();
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Rectangle r -> r.width() * r.height();
        };
    }

    public static void main(String[] args) {
        for (Shape shape : new Shape[]{new Square(2), new Circle(1), new Rectangle(2, 3)}) {
            System.out.printf("%-28s %.3f%n", shape, area(shape));
        }
    }
}

That is the trade the whole feature exists for: a little structure up front, in exchange for the compiler carrying the list of cases for you.

Record patterns take the object apart

public class RecordPatterns {
    sealed interface Shape permits Square, Circle { }
    record Point(double x, double y) { }
    record Square(Point origin, double side) implements Shape { }
    record Circle(Point centre, double radius) implements Shape { }

    static String describe(Shape shape) {
        return switch (shape) {
            // Destructure in the pattern: the components are bound directly,
            // including the nested Point.
            case Square(Point(var x, var y), var side) when side > 10 ->
                    "a big square at " + x + "," + y;
            case Square(Point p, var side) ->
                    "a square of " + side + " at " + p;
            case Circle(Point(var x, var y), var r) ->
                    "a circle of radius " + r + " at " + x + "," + y;
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(new Square(new Point(0, 0), 2)));
        System.out.println(describe(new Square(new Point(1, 1), 20)));
        System.out.println(describe(new Circle(new Point(3, 4), 5)));
    }
}

when adds a condition to a pattern. Order matters: the first matching case wins, so the guarded one has to come before the general one - and the compiler will tell you if a later case could never be reached.

Modelling a result, so the caller cannot ignore the failure

This is where the Level 2 lesson on exceptions meets this one. An expected outcome is data; a sealed result type makes the compiler insist it is handled.

public class Results {
    sealed interface Result<T> permits Success, Failure, Pending { }
    record Success<T>(T value) implements Result<T> { }
    record Failure<T>(String message) implements Result<T> { }
    record Pending<T>() implements Result<T> { }

    static String render(Result<Integer> result) {
        return switch (result) {
            case Success<Integer> s -> "value " + s.value();
            case Failure<Integer> f -> "failed: " + f.message();
            case Pending<Integer> ignored -> "still working";
        };
    }

    public static void main(String[] args) {
        java.util.List<Result<Integer>> results = java.util.List.of(
                new Success<>(42), new Failure<>("timeout"), new Pending<>());
        results.stream().map(Results::render).forEach(System.out::println);
    }
}

Delete the Pending case and the code stops compiling, naming the type you missed. A thrown exception offers no such guarantee: nothing makes a caller catch it, and nothing tells you when a new failure mode appears.

null in a switch

Historically switch (null) threw a NullPointerException. A pattern switch can name null as a case, which is both clearer and safer than a check before the statement.

public class Nulls {
    public static void main(String[] args) {
        for (Object value : new Object[]{"text", 42, null}) {
            System.out.println(describe(value));
        }
    }

    static String describe(Object value) {
        return switch (value) {
            case null -> "nothing at all";
            case String s -> "a string: " + s;
            case Integer i -> "an int: " + i;
            default -> "something else";
        };
    }
}

What to take away

  • instanceof Type t declares the variable and scopes it to where the test passed.
  • A switch over a sealed type needs no default, and is checked for exhaustiveness.
  • Adding a subtype then breaks compilation everywhere it should - by design.
  • Record patterns destructure, including nested records, and when guards a case.
  • A sealed result type makes the caller handle every outcome the compiler knows about.
  • A pattern switch can handle null as a case instead of throwing.