Pattern Matching in switch

Switch on types and record shapes with guards, null handling and compiler-checked exhaustiveness, for example to map domain errors to HTTP responses.

Syntaxreturn switch (value) { case Type t when condition -> ...; case Type t -> ...; case null, default -> ...; };

Since Java 21, case labels can hold patterns, not only constants. A switch can test the type of its selector, deconstruct records, add conditions and handle null, all in one construct that the compiler checks for completeness.

String label = switch (value) {
    case null -> "missing";
    case Integer i when i < 0 -> "negative";
    case Integer i -> "integer " + i;
    case String s -> "text " + s;
    default -> "other";
};

Guards

A when clause after a pattern adds a boolean condition, and the pattern's variables are in scope inside it. If the guard is false, matching moves on to the next case. Guarded cases do not count towards exhaustiveness, so each subtype still needs an unguarded case (or a default).

Order and dominance

Cases are tried top to bottom and the first match wins. The compiler rejects a case that can never be reached because an earlier one covers it: case CharSequence cs before case String s is an error, and so is an unguarded case Integer i before case Integer i when i < 0. Put specific cases first. The compiler cannot see overlap between two guards, though, so the order of guarded cases is your responsibility.

null

A switch without case null throws NullPointerException when the selector is null, just as switches always have. Add case null to handle it in place, or case null, default to send it to the default branch. This matters for values pulled from maps or parsed JSON, where null is common.

Exhaustiveness

A switch expression, and any switch statement that uses patterns or case null, must cover every possible value. Over a sealed interface, a case for each permitted subtype is enough. Leave default out on purpose: then adding a subtype breaks the build at every switch that needs updating. If code compiled against an older version of the hierarchy meets a new subtype at runtime anyway, the switch throws MatchException rather than silently doing nothing.

Back-end uses

The classic one is turning domain errors into HTTP responses in one place: a sealed ApiError with records for not found, invalid input and conflict, and one switch that picks the status and the message. The same shape suits routing commands, applying events and formatting loosely typed values.

Prefer polymorphism when the behaviour belongs to the types and the set is open. Prefer a pattern switch when the set is closed and the logic belongs to the caller, such as the web layer choosing status codes for domain types that should know nothing about HTTP.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Map a sealed hierarchy of domain errors to HTTP status codes and messages in one place in the web layer.
  • Format query parameters or JSON values of unknown type for logs, handling null without a separate check.
  • Route user events to email, audit and analytics handlers with a switch the compiler keeps complete.

More examples

Formatting loosely typed values, with case null

case null handles missing values in place; without it the switch throws even though it has a default. Guarded cases sit above the unguarded case for the same type, as dominance requires.

Example · java
String format(Object value) {
    return switch (value) {
        case null -> "null";
        case String s when s.isBlank() -> "\"\" (blank)";
        case String s -> "\"" + s + "\"";
        case Integer i when i < 0 -> i + " (negative)";
        case Integer i -> Integer.toString(i);
        case Boolean b -> b ? "yes" : "no";
        default -> value.getClass().getSimpleName();
    };
}

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

void main() {
    Object[] params = {"ada", " ", -3, 7, true, null, 2.5};
    for (Object p : params) {
        IO.println(format(p));
    }
    try {
        withoutNullCase(null);
    } catch (NullPointerException e) {
        IO.println("no case null: NullPointerException");
    }
}

// Output:
// "ada"
// "" (blank)
// -3 (negative)
// 7
// yes
// null
// Double
// no case null: NullPointerException

Guard order decides the result

The compiler cannot tell that the first two guards overlap, so swapping them would compile and quietly give gold customers 10%. Order guarded cases from most to least specific and cover the rules with tests.

Example · java
record Order(String tier, long totalPence) {}

String discount(Order order) {
    return switch (order) {
        case Order o when o.totalPence() >= 10_000 && o.tier().equals("gold") -> "15% off";
        case Order o when o.totalPence() >= 10_000 -> "10% off";
        case Order o when o.tier().equals("gold") -> "5% off";
        case Order o -> "no discount";
    };
}

void main() {
    IO.println(discount(new Order("gold", 12_000)));
    IO.println(discount(new Order("silver", 12_000)));
    IO.println(discount(new Order("gold", 5_000)));
    IO.println(discount(new Order("silver", 5_000)));
}

// Output:
// 15% off
// 10% off
// 5% off
// no discount

A switch statement with several patterns per case

A pattern switch statement must be exhaustive too, so adding an event type breaks this loop until it is handled. Several patterns can share one case only when they bind no variables, hence the _.

Example · java
sealed interface Event {}
record UserSignedUp(String email) implements Event {}
record UserLoggedIn(String email, String ip) implements Event {}
record PasswordReset(String email) implements Event {}
record UserDeleted(String email, String reason) implements Event {}

void main() {
    List<Event> events = List.of(
            new UserSignedUp("[email protected]"),
            new UserLoggedIn("[email protected]", "203.0.113.7"),
            new PasswordReset("[email protected]"),
            new UserDeleted("[email protected]", "requested"));
    for (Event event : events) {
        switch (event) {
            case UserSignedUp(var email) -> IO.println("send welcome email to " + email);
            case UserLoggedIn(var email, var ip) -> IO.println("login for " + email + " from " + ip);
            case PasswordReset _, UserDeleted _ -> IO.println("audit: " + event);
        }
    }
}

// Output:
// send welcome email to [email protected]
// login for [email protected] from 203.0.113.7
// audit: PasswordReset[[email protected]]
// audit: UserDeleted[[email protected], reason=requested]

Discussion

  • Be the first to comment on this lesson.