Polyglot
Languages Java pattern matching
Java § pattern-matching

Pattern matching

Java has incrementally added pattern matching across recent revisions: type patterns in instanceof (Java 16), pattern matching for switch (Java 21), and record patterns (Java 21). The pattern grammar is smaller than C#‘s — Java does not yet have list patterns or relational patterns — but the combination of type patterns, record patterns, and the sealed types mechanism gives Java a substantial discrimination surface. Combined with switch expressions, the pattern surface admits expressing what older Java code wrote as long if/else if cascades or visitor-pattern dispatchers in a more declarative form.

This page covers the pattern grammar; the related constructs are in Conditionals, Classes, records, and sealed types, and Interfaces.

The pattern grammar

A pattern matches a value and optionally binds parts of it to names. The principal forms in modern Java:

PatternExampleMatches
Constant42, "x", Color.RED, nullEqual to the given constant
TypeString sThe value is of the given type, binding s
RecordPoint(int x, int y)The value is a Point with deconstructed components
Varvar x (in some contexts)Binds to x; matches anything
Guardcase String s when s.length() > 5A pattern with a boolean guard

Java does not yet have:

  • Property patterns (C#‘s { Length: > 5 }).
  • List patterns (C#‘s [1, 2, ..]).
  • Relational patterns as standalone (> 0, <= 100).
  • Logical patterns (a and b, a or b, not a).

The proposed primitive type patterns (switch on int with relational patterns) is in preview as of Java 23; the proposed array patterns and the enriched constant patterns are in design discussion. The pattern surface is expected to grow significantly across the next several revisions.

Constant patterns

The simplest pattern: equality with a constant. Used in classical switch and switch expressions:

String describe(int n) {
    return switch (n) {
        case 0  -> "zero";
        case 1  -> "one";
        case -1 -> "minus one";
        default -> "other";
    };
}

String colorName(Color c) {
    return switch (c) {
        case RED   -> "red";
        case GREEN -> "green";
        case BLUE  -> "blue";
    };
}

Constants admit literals, enum members, named final constants, and (in switch) null.

Type patterns

A type pattern matches a value of the given type and (optionally) binds it to a name:

if (obj instanceof String) { /* obj is a String, but not bound */ }

if (obj instanceof String s) {
    System.out.println(s.length());     // s is bound and typed as String
}

The pattern String s matches if the value is a String (or a subtype) and binds it to s. The type pattern subsumes both the instanceof test and the cast — older code wrote if (obj instanceof String) { String s = (String) obj; … }.

Type patterns are admitted in if (since Java 16) and in switch (since Java 21):

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

The case label Circle c introduces a type pattern with a binding. Each arm is evaluated in order; the first matching pattern selects the corresponding expression.

null in switch

Classical switch rejected null as a discriminator with NullPointerException. The pattern-matching switch admits an explicit case null:

String classify(Object o) {
    return switch (o) {
        case null      -> "null";
        case String s  -> "string of length " + s.length();
        case Integer i -> "integer " + i;
        default        -> "other";
    };
}

If case null is not present and the discriminator is null, a NullPointerException is thrown. The conventional discipline is to handle null explicitly in switch expressions.

Record patterns

Java 21 introduced record patterns: deconstruction of a record into its components:

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

String describe(Object o) {
    return switch (o) {
        case Point(int x, int y) when x == 0 && y == 0 -> "origin";
        case Point(int x, int y) when x == y           -> "on diagonal at " + x;
        case Point(int x, int y)                        -> "(" + x + ", " + y + ")";
        default -> "not a point";
    };
}

The pattern Point(int x, int y) matches if the value is a Point and binds the components to the named locals. The component patterns themselves may be type patterns, record patterns (recursively), or var:

public record Pair<A, B>(A first, B second) { }

String describe(Object o) {
    return switch (o) {
        case Pair(String name, Integer age) -> name + " is " + age;
        case Pair(var first, var second)     -> first + ", " + second;
        default                              -> "unknown";
    };
}

The var form admits binding without specifying the type — useful when the type is not the discrimination criterion.

Recursive record patterns

Patterns nest:

public record Box<T>(T contents) { }
public record Wrapper<T>(Box<T> box, String label) { }

String describe(Wrapper<String> w) {
    return switch (w) {
        case Wrapper(Box(String s), String label) -> label + ": " + s;
        case Wrapper(Box(var x), String label)     -> label + ": " + x;
    };
}

The pattern Wrapper(Box(String s), String label) matches a Wrapper whose box is a Box<String> and whose label is non-null; both s and label are bound.

Guards

Patterns may carry a when clause for an arbitrary boolean condition:

String classify(Shape s) {
    return switch (s) {
        case Circle c when c.radius() > 0    -> "circle with radius " + c.radius();
        case Circle c                         -> "degenerate circle";
        case Square sq when sq.side() == 0    -> "point";
        case Square sq                         -> "square";
    };
}

The guard when c.radius() > 0 makes the case match only when the predicate is true. Guards admit value-level discrimination in addition to type-level.

The arms are evaluated top-to-bottom; for each potential match, the type pattern is tested first, then the guard. A failing guard does not “fall through” to other arms with the same type pattern — the next arm in source order is tested.

Var patterns

The var pattern matches anything and binds:

case var x -> use(x);

case Point(var x, var y) -> /* x and y bound, types inferred from the record */;

The construction is principally useful inside record patterns; standalone var patterns are admitted in some contexts but rare.

The discard

Java does not yet have a discard pattern (the _ of C# and Scala). The proposed unnamed patterns and variables (preview in Java 21) introduce _ as a name for “ignore this”:

// preview syntax (Java 21+):
case Point(_, var y) -> "y = " + y;
case _ -> "anything";

Until the feature ships finally, the conventional alternative is to use var with a meaningful name or to ignore the binding:

case Point(int unused, int y) -> "y = " + y;
case Point(var x, var y) -> /* x is bound but not used */;

Switch expressions

The switch expression combines patterns and arms; the arms produce values:

double area(Shape s) {
    return switch (s) {
        case null              -> throw new NullPointerException();
        case Circle(var r)     -> Math.PI * r * r;
        case Square(var side)  -> side * side;
        case Triangle(var b, var h) -> 0.5 * b * h;
    };
}

The arms are evaluated top to bottom; the first matching pattern selects the corresponding expression. Each arm may have a when clause for an arbitrary boolean guard.

The compiler tries to determine whether the patterns cover every possible input; for switch expressions (not statements), exhaustiveness is required — the compiler emits an error if not every case is covered.

For sealed types, the compiler tracks the subtype set and enforces exhaustiveness automatically:

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

double area(Shape s) {
    return switch (s) {
        case Circle c   -> Math.PI * c.radius() * c.radius();
        case Square sq  -> sq.side() * sq.side();
        case Triangle t -> 0.5 * t.base() * t.height();
        // exhaustive: every permitted subtype is covered
    };
}

If a new permitted subtype is added without updating the switch, the compiler emits an error — the closest Java comes to compile-time-checked algebraic data types.

Pattern matching with instanceof

The instanceof operator admits the type-pattern grammar (Java 16+):

if (value instanceof String s) {
    /* s is in scope and typed as String */
}

if (value instanceof Point(int x, int y)) {     // Java 21+
    System.out.println("(" + x + ", " + y + ")");
}

if (value instanceof Circle c && c.radius() > 0) {
    /* c is in scope; the && extends the binding */
}

The instanceof pattern is the conventional contemporary replacement for type-test casts. The pattern’s bindings are scoped to the consequent branch:

if (shape instanceof Circle c) {
    /* c is in scope */
} else {
    /* c is NOT in scope here */
}

Sealed types and exhaustive matching

The sealed-type mechanism (Java 17) admits closed type hierarchies:

public sealed interface JsonValue
    permits JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject { }

public record JsonNull()                         implements JsonValue { }
public record JsonBool(boolean value)             implements JsonValue { }
public record JsonNumber(double value)            implements JsonValue { }
public record JsonString(String value)            implements JsonValue { }
public record JsonArray(List<JsonValue> items)    implements JsonValue { }
public record JsonObject(Map<String, JsonValue> entries) implements JsonValue { }

The permits clause restricts which classes may implement the interface; combined with switch expressions, the compiler enforces exhaustiveness:

String render(JsonValue v) {
    return switch (v) {
        case JsonNull n             -> "null";
        case JsonBool(boolean b)    -> Boolean.toString(b);
        case JsonNumber(double n)   -> Double.toString(n);
        case JsonString(String s)   -> "\"" + s + "\"";
        case JsonArray(List<JsonValue> items) ->
            items.stream().map(this::render).collect(Collectors.joining(",", "[", "]"));
        case JsonObject(Map<String, JsonValue> entries) ->
            entries.entrySet().stream()
                .map(e -> "\"" + e.getKey() + "\":" + render(e.getValue()))
                .collect(Collectors.joining(",", "{", "}"));
    };
}

The construction is the closest Java comes to algebraic-data-type pattern matching. Adding a new JsonValue permitted subtype produces a compile-time error in every existing switch expression — a substantial discipline aid.

Common patterns

Type-and-bind in if

if (obj instanceof String s && !s.isEmpty()) {
    process(s);
}

The pattern admits the type test, the cast, and a guard in one expression.

Record destructuring

public record Result<T>(boolean ok, T value, String error) { }

void handle(Result<User> r) {
    switch (r) {
        case Result(true, var user, _) -> useUser(user);
        case Result(false, _, var msg) -> reportError(msg);
    }
}

(The _ here is illustrative; actual Java requires a named var until the unnamed pattern feature ships.)

Sealed-type dispatch

public sealed interface Event permits Click, KeyPress, Scroll { }

void handle(Event e) {
    switch (e) {
        case Click(int x, int y)    -> handleClick(x, y);
        case KeyPress(int code)     -> handleKey(code);
        case Scroll(int delta)      -> handleScroll(delta);
    }
}

The construction is the conventional Java idiom for sum-type dispatch.

Type ladder with guards

String classify(Object o) {
    return switch (o) {
        case null              -> "null";
        case Integer i when i > 0  -> "positive int";
        case Integer i when i < 0  -> "negative int";
        case Integer i              -> "zero";
        case String s when s.isEmpty() -> "empty string";
        case String s              -> "non-empty string";
        default                    -> "other";
    };
}

The combination of type patterns and guards admits substantial discrimination; for cases too complex for the pattern grammar alone, an if-chain inside the arm body is the conventional fallback.

A note on the trajectory

Pattern matching is one of the most actively-developed areas of the Java language. Each subsequent revision is expected to add capabilities — primitive-type patterns, array patterns, range patterns, additional binding forms — toward parity with the more elaborate pattern systems in Scala and modern functional languages. The current surface is adequate for the principal use cases (type discrimination, record destructuring, sealed-type dispatch); programs that need richer matching can use Scala-on-the-JVM or wait for the upcoming Java additions.