Pattern Matching & Sealed Types

Modern Java (16–21) added a family of features that work together: pattern matching for instanceof and switch, record patterns that take records apart, and sealed types that declare exactly which subclasses exist. The result is a clean, compiler-checked way to model data with a fixed set of shapes — "data-oriented programming".

Pattern matching for instanceof

JAVA
public class Main {
    static String describe(Object o) {
        if (o instanceof String s && !s.isEmpty()) {
            return "text of length " + s.length();
        }
        if (o instanceof Integer n) {
            return "number " + (n * 2) + " when doubled";
        }
        return "something else";
    }

    public static void main(String[] args) {
        System.out.println(describe("hello"));
        System.out.println(describe(21));
        System.out.println(describe(3.5));
    }
}
Output
text of length 5
number 42 when doubled
something else

o instanceof String s tests the type and declares s as the cast value — no separate cast needed.

Switch with type patterns

JAVA
public class Main {
    static String format(Object value) {
        return switch (value) {
            case null -> "null!";
            case Integer i when i < 0 -> "negative " + i;
            case Integer i -> "int " + i;
            case Double d -> "double %.2f".formatted(d);
            case String s -> "string \"" + s + "\"";
            default -> "other: " + value.getClass().getSimpleName();
        };
    }

    public static void main(String[] args) {
        System.out.println(format(-4));
        System.out.println(format(4));
        System.out.println(format(Math.PI));
        System.out.println(format("hi"));
        System.out.println(format(null));
        System.out.println(format(new int[0]));
    }
}
Output
negative -4
int 4
double 3.14
string "hi"
null!
other: int[]

Since Java 21, switch can match on types, with when guards for extra conditions. Cases are checked in order, so more specific ones go first. A switch can also handle null explicitly.

Sealed types

JAVA
sealed interface Shape permits Circle, Square, Rectangle {}

record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

public class Main {
    static double area(Shape shape) {
        return switch (shape) {
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Square s -> s.side() * s.side();
            case Rectangle r -> r.width() * r.height();
        };
    }

    public static void main(String[] args) {
        Shape[] shapes = {new Circle(1), new Square(2), new Rectangle(2, 5)};
        for (Shape s : shapes) {
            System.out.println("%s -> %.2f".formatted(s, area(s)));
        }
    }
}
Output
Circle[radius=1.0] -> 3.14
Square[side=2.0] -> 4.00
Rectangle[width=2.0, height=5.0] -> 10.00

sealed … permits lists every allowed implementation; no other class can implement Shape. Because the compiler knows all the cases, the switch needs no default — and if you add a Triangle later, every switch that doesn't handle it becomes a compile error. Subclasses of a sealed type must be final, sealed or non-sealed (records are implicitly final).

Record patterns

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

public class Main {
    static int eval(Expr e) {
        return switch (e) {
            case Num(int v) -> v;
            case Add(Expr l, Expr r) -> eval(l) + eval(r);
            case Mul(Num(var a), Num(var b)) -> a * b;
            case Mul(Expr l, Expr r) -> eval(l) * eval(r);
        };
    }

    static String show(Expr e) {
        return switch (e) {
            case Num(int v) -> String.valueOf(v);
            case Add(var l, var r) -> "(" + show(l) + " + " + show(r) + ")";
            case Mul(var l, var r) -> show(l) + " * " + show(r);
        };
    }

    public static void main(String[] args) {
        Expr e = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
        System.out.println(show(e) + " = " + eval(e));
    }
}
Output
(2 + 3) * 4 = 20

A record pattern like Add(Expr l, Expr r) matches the type and extracts its components in one step, and patterns can be nested (Mul(Num(var a), Num(var b))). This style makes tree-shaped data — expressions, JSON, commands, events — remarkably easy to process.

Modelling results

JAVA
sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String transactionId) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(int retryAfterSeconds) implements PaymentResult {}

public class Main {
    static String message(PaymentResult r) {
        return switch (r) {
            case Approved(var id) -> "Paid (ref " + id + ")";
            case Declined(var reason) -> "Declined: " + reason;
            case Pending(var secs) when secs > 60 -> "Still processing, check back later";
            case Pending(var secs) -> "Retrying in " + secs + "s";
        };
    }

    public static void main(String[] args) {
        System.out.println(message(new Approved("TX-881")));
        System.out.println(message(new Declined("card expired")));
        System.out.println(message(new Pending(30)));
    }
}
Output
Paid (ref TX-881)
Declined: card expired
Retrying in 30s

Sealed interfaces of records describe "one of these shapes" precisely, with each shape carrying exactly the data it needs. It's often clearer than one class with many nullable fields.

Try it yourself

Model vehicles as a sealed hierarchy and calculate a toll for each.

Try it yourself · Main.java
Edit the code, copy it into Main.java and run java Main.java (Java 25+). How to set up Java
Output
Car[passengers=1] pays 4.0
Car[passengers=4] pays 2.0
Truck[axles=3, tonnes=25.0] pays 25.0
Motorbike[] pays 1.5