Back to Blog
Java

Java Switch Expression Pattern Matching

java switch expression pattern matching: Learn how Java switch expression pattern matching works: type patterns, guarded cases, sealed-type exhaustiveness, null handli...

Javaswitch expressionspattern matchingtype patternssealed classesJava 21
Illustration of a Java switch expression routing different shape types into distinct result panels, representing type pattern matching.

Java switch expression pattern matching, finalized in Java 21, combines the value-producing form of a switch expression with type patterns that bind variables directly in case labels. A single case can test the runtime type of the selector, bind the matched value to a variable, and produce a result without an explicit cast or a separate instanceof check.

sealed interface Shape permits Circle, Rectangle, Triangle {} record Circle(double radius) implements Shape {} record Rectangle(double width, double height) 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 Rectangle r -> r.width() * r.height(); case Triangle t -> 0.5 * t.base() * t.height(); }; }

The compiler verifies that the switch is exhaustive because Shape is sealed and every permitted subtype has a case. This is the core benefit of the feature: the relationship between data shape and dispatch logic is explicit and checked at compile time.

What Switch Expressions Change

A switch expression, available since Java 14, differs from the classic switch statement in three ways. It always produces a value, it uses arrow labels that do not fall through, and it requires every possible input to be covered. The arrow form also allows a block on the right side of the label, with yield returning the value:

String describe(int code) { return switch (code) { case 200 -> "OK"; case 404 -> "Not found"; case 500 -> { log.error("Server error for code {}", code); yield "Server error"; } default -> "Unknown"; }; }

Pattern matching builds on this form. The selector expression can be any object, and each case label can be a type pattern rather than a constant. That removes the typical boilerplate of an instanceof check, a cast, and a nested conditional.

Type Patterns in Case Labels

A type pattern in a case label has the form TypeName variableName. It matches when the selector is an instance of that type, and it binds the selector to the variable, already cast, for the duration of the case block.

Object value = repository.find(id); String label = switch (value) { case String s -> "Text with " + s.length() + " characters"; case Integer i -> "Integer " + i; case null -> "No value"; default -> "Unknown type"; };

The variable s is a String, not an Object, so calling s.length() requires no cast. The scope of the pattern variable is the case block only; it is not visible in other cases or after the switch. This is a deliberate restriction that prevents accidental use of a variable that may not have been bound.

Guarded Patterns and When Clauses

A guarded pattern adds a boolean condition after the type pattern with the when keyword. The case matches only when both the type pattern matches and the guard evaluates to true. If the guard fails, the switch continues to the next case.

String size(Object text) { return switch (text) { case String s when s.length() > 100 -> "long"; case String s when s.length() > 20 -> "medium"; case String s -> "short"; default -> "not a string"; }; }

Order matters here. The cases are evaluated top to bottom, and the first matching case wins. A guarded case placed after an unguarded case of the same type would be unreachable for the values that reach it, because the unguarded case already matched. The compiler rejects such dominance with a compile error rather than silently producing dead code.

Exhaustiveness with Sealed Types

A switch expression must be exhaustive. For a selector of a sealed type, the compiler can check exhaustiveness by inspecting the permitted subtypes. If every permitted subtype has a matching case, the default label is optional.

String kind(Shape shape) { return switch (shape) { case Circle c -> "circle"; case Rectangle r -> "rectangle"; case Triangle t -> "triangle"; }; }

If a new subtype is added to the sealed hierarchy, this switch stops compiling until the new case is added. That is the intended workflow: the compiler points to every switch that needs updating. For non-sealed types, or when the selector type is Object, a default case is required unless the cases cover all possible runtime types, which the compiler cannot prove in general.

Null Handling in Pattern-Matching Switches

A classic switch statement throws NullPointerException when the selector is null. A switch expression with type patterns behaves differently: a case null label matches the null value explicitly. Without a case null and without a default, the switch still throws NullPointerException.

String describe(Object value) { return switch (value) { case null -> "null"; case String s -> "string"; default -> "other"; }; }

The case null label is useful when null is a legitimate input that should map to a specific result. If null should be rejected, omitting the null case and letting the default handle it, or letting the NPE propagate, is the simpler choice. Note that case null cannot be combined with a type pattern in the same label; it is a standalone label.

Runtime Cost and Code Generation

Pattern matching in switch does not rely on reflection. The compiler translates each type pattern into a runtime type check, similar to an instanceof test, followed by a cast when the check succeeds. Guarded patterns add the guard condition as an additional branch after the type check. The generated bytecode is therefore comparable to a hand-written chain of instanceof checks, and the JIT can optimize the type tests the same way it optimizes ordinary instanceof usage.

The practical implication is that the feature is not a performance risk in normal use. The cost is a sequence of type checks proportional to the number of cases, evaluated in source order. If the selector type distribution is known, placing the most frequent type first can reduce the average number of checks, but the difference is usually small and should not drive design decisions without profiling.

Compatibility and Migration

Pattern matching for switch was finalized in Java 21. Switch expressions themselves were finalized in Java 14. Code that uses type patterns, guarded patterns, or case null in a switch requires a JDK 21 or newer compiler and runtime. On older JDKs, the code will not compile.

When migrating an existing if-else chain that checks instanceof and casts, the switch form is usually clearer because the type test, the binding, and the result are in one place. The main constraint is the exhaustiveness requirement: an if-else chain can silently do nothing when no branch matches, while a switch expression must either cover all cases or include a default. That is a behavior change, not just a syntax change, and it is worth verifying that the default behavior matches the old fall-through path.

Common Pitfalls and Edge Cases

A few details regularly cause problems when the feature is used in real code.

Dominance errors occur when an earlier case makes a later case unreachable. A case Object o before a case String s is a compile error because every string is already an object. The same applies to guarded cases: case String s before case String s when s.length() > 5 makes the guarded case unreachable.

Exhaustiveness with guards can be subtle. A switch over a sealed type where every subtype has a guarded case is not automatically exhaustive, because a guard can fail. The compiler requires a default in that situation unless there is an unguarded case that covers the remaining values.

Pattern variables are scoped to their case block. They cannot be referenced in another case or after the switch. This is intentional, but it surprises developers who expect the variable to behave like a local variable declared before the switch.

Record patterns can be combined with switch patterns for nested destructuring. A case like case Point(int x, int y) -> ... matches a Point record and binds its components. This works with guarded patterns as well, but the combination increases the complexity of the exhaustiveness analysis, and the compiler will require a default when it cannot prove coverage.

java switch expression pattern matching: Practical Usage and | RYUSLOG DEV