Back to Blog
Java

Java Pattern Matching Switch

java pattern matching switch: Learn how to use pattern matching for switch in Java: syntax, guarded patterns, exhaustiveness, null handling, and practical migration tips.

JavaPattern MatchingSwitch ExpressionsType PatternsJava 21
Illustration of a Java switch statement with type patterns, showing a value being matched against multiple type cases.

Java pattern matching switch extends the traditional switch statement and expression with type patterns, letting you test a value against a type and extract its components in a single step. This feature, finalized in Java 21, removes much of the boilerplate that previously required instanceof checks followed by explicit casts. Instead of writing a chain of if-else blocks, you can express the same logic as a switch that directly matches on the runtime type of the selector expression.

Basic Syntax and Type Patterns

The core of pattern matching for switch is the type pattern, written as a type name followed by a binding variable. When the selector value matches the type, the variable is assigned the value cast to that type, and the corresponding branch executes. Here is a minimal example:

public static String describe(Object value) { return switch (value) { case Integer i -> "Integer: " + i; case String s -> "String length: " + s.length(); case null -> "null"; default -> "Unknown type"; }; }

This switch expression evaluates value and selects the first matching pattern. The case Integer i pattern matches only if value is an instance of Integer, and then binds the cast value to i. The case null pattern is a special null pattern that matches a null selector, which is necessary because type patterns do not match null. The default branch handles any other type. Without the case null branch, a null selector would throw NullPointerException unless a default branch is present and handles it.

Type patterns work with any reference type, including interfaces, abstract classes, and generic types. The binding variable is effectively final within the branch, so you can use it in lambda expressions or method references without additional restrictions.

Guarded Patterns and Conditions

A type pattern alone only checks the type. To also require a property of the matched value, use a guarded pattern with the when keyword. The guard is a boolean expression evaluated only after the type pattern matches. If the guard evaluates to false, the pattern does not match, and the switch continues to the next case.

public static String classify(Object value) { return switch (value) { case String s when s.length() > 10 -> "Long string"; case String s -> "Short string"; case Integer i when i < 0 -> "Negative integer"; case Integer i -> "Non-negative integer"; default -> "Other"; }; }

Guards allow you to combine type checks with conditions without nested if statements. The guard is evaluated in the scope of the binding variable, so you can call methods on it directly. If the guard throws an exception, the exception propagates, and the pattern is considered not matched for the purpose of continuing, but the exception is not caught by the switch.

Exhaustiveness and Default Handling

When using switch as an expression, the compiler requires that all possible values of the selector are covered. For an int selector, you need a default branch unless every possible integer is listed, which is impractical. For an enum type, the compiler can verify exhaustiveness if you cover all enum constants and include a null case if needed. For a String or other reference type, you must provide a default branch because the set of possible types is unbounded.

With pattern matching, exhaustiveness becomes more nuanced. If you have a sealed interface, the compiler knows all permitted subtypes. A switch expression over a sealed type can be exhaustive without a default if you provide a case for every permitted subtype. For example:

sealed interface Shape permits Circle, Rectangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) implements Shape {} public static double area(Shape shape) { return switch (shape) { case Circle c -> Math.PI * c.radius() * c.radius(); case Rectangle r -> r.width() * r.height(); }; }

Here, the compiler knows that Shape can only be a Circle or a Rectangle, so the switch expression is exhaustive without a default. If you later add another permitted subtype, the code will not compile until you add a corresponding case. This compile-time safety is one of the main advantages of pattern matching for switch.

For non-sealed types, you still need a default branch to satisfy the compiler. The default branch also handles null if no case null is present, but it is clearer to write an explicit case null when you want to handle null distinctly.

Null Handling and Scope

The null pattern case null is a special case that matches only a null selector. It is useful for avoiding NullPointerException and for providing a distinct null response. In a switch statement (not expression), a case null is allowed and will be matched. If you omit case null and there is no default, a null selector throws NullPointerException. If you include a default but no case null, the null value goes to the default branch.

public static String check(Object value) { return switch (value) { case null -> "Null value"; case String s -> "String: " + s; default -> "Other"; }; }

The scope of a pattern variable is limited to the branch where it is declared. You cannot use the binding variable outside the case block. In a switch expression, each branch is an expression, so the variable is scoped to that expression. In a switch statement, the variable is scoped to the block of that case. This prevents accidental use of a variable that might not be assigned.

Performance and Runtime Behavior

Pattern matching for switch does not introduce significant runtime overhead. The compiler translates type patterns into instanceof checks and casts, similar to what you would write manually. The JIT compiler can optimize these checks, especially when the hierarchy is sealed or the types are known. There is no reflection or dynamic dispatch beyond what instanceof already does.

One performance consideration is the order of cases. The switch evaluates patterns in the order they appear. If you have a broad pattern like Object o before a specific pattern like String s, the broad pattern will match first, and the specific one will never be reached. The compiler does not reorder patterns, so you must order them from most specific to least specific. This is a common source of bugs.

Another runtime behavior to note is that pattern matching for switch is null-safe only if you include a null pattern or a default that handles null. Otherwise, a null selector will throw NullPointerException at runtime. This is consistent with traditional switch behavior for reference types.

Common Mistakes and Edge Cases

A frequent mistake is assuming that type patterns are ordered by specificity automatically. They are not. Consider this example:

switch (value) { case Object o -> "Object"; case String s -> "String"; }

This will not compile because Object is a supertype of String, and the compiler detects that the String case is unreachable. The compiler enforces that no pattern can be dominated by a previous pattern. This compile-time check prevents accidental unreachable code.

Another edge case is using a pattern variable in a guard after a type pattern that is not exhaustive. For example, case Integer i when i > 0 is fine, but if you have case Number n and then case Integer i, the second is unreachable because Integer is a subtype of Number. The compiler will reject it.

When using records, you can use a record pattern to deconstruct the record directly. For example:

record Point(int x, int y) {} public static String describe(Object obj) { return switch (obj) { case Point(int x, int y) -> "Point at " + x + ", " + y; default -> "Not a point"; }; }

Record patterns work with type patterns and can be nested. This is particularly useful for complex data structures.

Compatibility and Migration

Pattern matching for switch was introduced as a preview in Java 17 and became final in Java 21. If you are using an earlier version, you need to enable the preview feature with --enable-preview. For production code, you should target Java 21 or later to use the feature without flags. The syntax is backward compatible in the sense that existing switch statements and expressions continue to work unchanged; the new patterns are additive.

When migrating existing if-else chains with instanceof to switch, the logic often becomes more readable and less error-prone. However, you should be careful about the order of cases and null handling. A typical refactoring might look like this:

Before:

if (obj instanceof String s) { return s.length(); } else if (obj instanceof Integer i) { return i; } else { return 0; }

After:

return switch (obj) { case String s -> s.length(); case Integer i -> i; default -> 0; };

The switch version is more concise and makes the decision structure explicit. It also benefits from compiler exhaustiveness checks when used with sealed types.

One compatibility concern is that the case null pattern is new. In older Java versions, you would handle null in the default branch or before the switch. When migrating, you may need to add a case null to preserve behavior if you previously relied on default to catch null.

Another migration consideration is that pattern matching for switch is not available for primitive types other than int, char, byte, short, and enum in the traditional sense. Type patterns apply to reference types only. For primitives, you still use constant cases. This limitation is unlikely to change in the near future.

Finally, remember that the binding variable in a pattern is not mutable. If you need to modify the value, you must assign it to a new variable inside the branch. This aligns with the general principle of preferring immutable local variables.

java pattern matching switch: Practical Usage and Code Examp | RYUSLOG DEV