PrepZone Logo
PrepZone

Pattern Matching for switch

Record patterns, destructuring and exhaustiveness — data-oriented programming in practice.

Why this matters

  • Sealed types, records and pattern matching were designed as one feature in three parts. This article is where they combine, and it is the finished form of data-oriented Java.
  • Destructuring removes the accessor noise from code that walks a data structure, which is most parsing, interpreting and transforming code.
  • Exhaustive switches mean adding a case to your model produces a list of compile errors telling you exactly what to update.

Patterns in switch

Java
// Java 21 — switch over any reference type, with type patterns
String format(Object value) {
    return switch (value) {
        case Integer i -> "int " + i;
        case Long l -> "long " + l;
        case String s -> "string of length " + s.length();
        case int[] array -> "int array of " + array.length;
        case null -> "nothing";
        default -> "unknown: " + value.getClass().getSimpleName();
    };
}
Java
// With a sealed hierarchy, no default is needed and the switch is exhaustive
sealed interface Shape permits Circle, Square, Triangle { }
record Circle(double radius) implements Shape { }
record Square(double side) implements Shape { }
record Triangle(double base, double height) implements Shape { }

double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Square s -> s.side() * s.side();
        case Triangle t -> 0.5 * t.base() * t.height();
    };
}

Null in switch

This is a genuine behaviour change worth knowing precisely.

Java
// Traditional switch throws NullPointerException on a null selector
switch (someString) { case "a": break; }       // NPE if someString is null

// A switch with patterns can match null explicitly
switch (value) {
    case null -> handleMissing();
    case String s -> handle(s);
    default -> handleOther();
}

Record patterns

A record pattern matches the type and binds the components by position.

Java
record Point(int x, int y) { }

// Type pattern, then manual accessors
if (object instanceof Point point) {
    int sum = point.x() + point.y();
}

// Record pattern — destructured in the match
if (object instanceof Point(int x, int y)) {
    int sum = x + y;                            // x and y are bound directly
}
Object oUnknown type
x1Nested component
y1Nested component
p2Whole record
One pattern tests the type and names the parts in a single step, including nested records — so there are no intermediate accessor calls to get wrong.

Nested patterns

This is where destructuring earns its place.

Java
record Point(int x, int y) { }
record Line(Point start, Point end) { }
record Path(Line first, Line second) { }

// Without patterns
if (object instanceof Path path) {
    Line first = path.first();
    Point start = first.start();
    int x = start.x();
    // ... four lines to reach one value
}

// With a nested record pattern
if (object instanceof Path(Line(Point(int x1, int y1), Point p2), Line second)) {
    // x1, y1, p2 and second are all bound, and the whole shape was verified
}
Java
// var infers each component type, which keeps deep patterns readable
if (object instanceof Line(var start, var end)) {
    double length = Math.hypot(end.x() - start.x(), end.y() - start.y());
}

Guarded patterns with when

Java
String classify(Shape shape) {
    return switch (shape) {
        case Circle c when c.radius() > 100 -> "huge circle";
        case Circle c -> "circle";
        case Square s when s.side() == 0 -> "degenerate square";
        case Square s -> "square";
        case Triangle t -> "triangle";
    };
}
Java
// Guards combine naturally with destructuring
String describe(Object object) {
    return switch (object) {
        case Point(int x, int y) when x == 0 && y == 0 -> "origin";
        case Point(int x, int y) when x == y -> "on the diagonal";
        case Point(int x, int y) -> "point at " + x + "," + y;
        default -> "not a point";
    };
}

A worked example: a small expression evaluator

This is the canonical case for data-oriented programming, and it shows every piece working together.

Java
sealed interface Expr { }
record Num(double value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Mul(Expr left, Expr right) implements Expr { }
record Neg(Expr operand) implements Expr { }
Java
double eval(Expr expr) {
    return switch (expr) {
        case Num(double value) -> value;
        case Add(Expr left, Expr right) -> eval(left) + eval(right);
        case Mul(Expr left, Expr right) -> eval(left) * eval(right);
        case Neg(Expr operand) -> -eval(operand);
    };
}
Java
// Simplification rules read almost like the mathematics
Expr simplify(Expr expr) {
    return switch (expr) {
        case Mul(Num(double a), Expr ignored) when a == 0 -> new Num(0);
        case Mul(Expr e, Num(double b)) when b == 1 -> simplify(e);
        case Add(Num(double a), Num(double b)) -> new Num(a + b);
        case Neg(Neg(Expr inner)) -> simplify(inner);
        default -> expr;
    };
}

Design guidance

AspectData-oriented — sealed + records + switchObject-oriented — interface + overriding
Where logic livesIn switches, grouped by operationIn each type, grouped by type
Adding an operationOne new method in one placeA new method in every subtype
Adding a typeEvery switch breaks until handledThe compiler demands the new implementations
Best whenThe data model is stableThe set of types keeps growing
Typical useParsers, interpreters, protocol handling, state machinesPlugins, strategies, framework extension points
  • Where logic lives

    Data-oriented — sealed + records + switchIn switches, grouped by operation
    Object-oriented — interface + overridingIn each type, grouped by type
  • Adding an operation

    Data-oriented — sealed + records + switchOne new method in one place
    Object-oriented — interface + overridingA new method in every subtype
  • Adding a type

    Data-oriented — sealed + records + switchEvery switch breaks until handled
    Object-oriented — interface + overridingThe compiler demands the new implementations
  • Best when

    Data-oriented — sealed + records + switchThe data model is stable
    Object-oriented — interface + overridingThe set of types keeps growing
  • Typical use

    Data-oriented — sealed + records + switchParsers, interpreters, protocol handling, state machines
    Object-oriented — interface + overridingPlugins, strategies, framework extension points

Both compile-time guarantees are real. Choose the one that matches the axis you expect to change.

Unnamed patterns

Java 21 also previewed _ for a binding you do not need, which was finalised in Java 22.

Java
// Preview in 21, standard from 22
switch (shape) {
    case Circle(double _) -> "circle";              // the radius is irrelevant here
    case Square(double side) -> "square " + side;
    case Triangle _ -> "triangle";
}

Common misreadings

  • "A pattern switch never throws on null." It throws unless there is an explicit case null. default does not match null.
  • "A record pattern needs the component names to match." Patterns bind by position, not name, so you may use any binding name.
  • "Case order is free." A more general pattern before a more specific one is a compile error for being unreachable.
  • "default is always allowed." Over a sealed hierarchy with every case covered, the compiler rejects a default as unreachable.
  • "Record patterns match null components." A nested type pattern rejects null; use var if a component is nullable.
  • "Pattern matching replaces polymorphism." It is the better choice on a different axis — stable data, growing operations.
  • "when is a keyword." It is contextual, so existing variables and methods named when still compile.

Quick recall

Everything you need if you only revisit this box.

  • switch accepts type patterns over any reference type, plus case null — without which null still throws, and default alone does not match null.
  • Over a sealed hierarchy the switch is exhaustive: no default is needed, and default is rejected as unreachable.
  • A record pattern destructures by position: case Point(int x, int y). Patterns nest arbitrarily, and var infers each component type.
  • A nested type pattern rejects null, so the whole pattern fails on a null component.
  • when guards add conditions; a more specific case must come before a more general one or it is a compile error.
  • Sealed + records + switch is data-oriented programming: ideal for parsers, interpreters and state machines where the data model is stable and operations grow.
  • Unnamed patterns (_) were preview in 21, standard in 22.

Test yourself

Answer these before moving on — recall is what makes it stick.