Skip to content
Codeloom
Java

Pattern Matching with Switch Expressions in Java

Master Java pattern matching in switch expressions. Covers type patterns, guarded patterns, sealed class exhaustiveness, and record deconstruction patterns.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How switch expressions differ from switch statements
  • How type patterns replace instanceof chains
  • How guarded patterns add conditions to cases

Prerequisites

  • Java fundamentals (control flow, inheritance)
  • Familiarity with records and sealed classes
  • Java 21+

Java 21 finalized pattern matching for switch expressions, completing a multi-release evolution that started with switch expressions in Java 14 and instanceof pattern matching in Java 16. This feature eliminates verbose instanceof-cast chains and pairs with sealed classes for exhaustive, compiler-checked branching.

Switch expressions vs switch statements

Before diving into pattern matching, understand the distinction between switch expressions and switch statements. Switch expressions produce a value and use the arrow (->) syntax.

// switch statement (traditional): no value, fall-through possible
switch (day) {
    case MONDAY:
    case FRIDAY:
        System.out.println("Busy");
        break;
    case SUNDAY:
        System.out.println("Rest");
        break;
    default:
        System.out.println("Normal");
}

// switch expression: produces a value, no fall-through
String mood = switch (day) {
    case MONDAY, FRIDAY -> "Busy";
    case SUNDAY -> "Rest";
    default -> "Normal";
};

Switch expressions are exhaustive. The compiler ensures every possible input is handled, either through explicit cases or a default.

Type patterns: replacing instanceof chains

The most impactful use of pattern matching is replacing chains of instanceof checks with a clean switch.

// before: instanceof chain
public String describe(Object obj) {
    if (obj instanceof String s) {
        return "String of length " + s.length();
    } else if (obj instanceof Integer i) {
        return "Integer: " + i;
    } else if (obj instanceof List<?> list) {
        return "List with " + list.size() + " elements";
    } else {
        return "Unknown: " + obj.getClass().getSimpleName();
    }
}

// after: pattern matching switch
public String describe(Object obj) {
    return switch (obj) {
        case String s    -> "String of length " + s.length();
        case Integer i   -> "Integer: " + i;
        case List<?> list -> "List with " + list.size() + " elements";
        default          -> "Unknown: " + obj.getClass().getSimpleName();
    };
}

The pattern variable (s, i, list) is automatically cast and scoped to the case branch. No explicit casting needed.

Guarded patterns with when

You can add conditions to pattern cases using the when keyword:

public String categorize(Object obj) {
    return switch (obj) {
        case String s when s.isEmpty()     -> "Empty string";
        case String s when s.length() > 100 -> "Long string (" + s.length() + " chars)";
        case String s                       -> "String: " + s;
        case Integer i when i < 0           -> "Negative number";
        case Integer i when i == 0          -> "Zero";
        case Integer i                      -> "Positive number: " + i;
        case null                           -> "Null value";
        default                             -> "Other: " + obj;
    };
}

Order matters. The compiler evaluates cases top to bottom, and a guarded pattern must appear before the unguarded version of the same type. Placing case String s before case String s when s.isEmpty() would make the guarded case unreachable.

Null handling in switch

Before Java 21, passing null to a switch always threw NullPointerException. Pattern matching switch allows explicit null handling:

public String process(String input) {
    return switch (input) {
        case null          -> "No input provided";
        case String s when s.isBlank() -> "Blank input";
        case String s      -> "Processing: " + s;
    };
}

If you do not include a case null, passing null still throws NullPointerException.

Exhaustiveness with sealed classes

Pattern matching and sealed classes are designed to work together. When you switch over a sealed type, the compiler knows every possible subtype and can verify you handle all of them.

public sealed interface Notification permits EmailNotif, SmsNotif, PushNotif {}
public record EmailNotif(String to, String subject, String body) implements Notification {}
public record SmsNotif(String phone, String message) implements Notification {}
public record PushNotif(String deviceToken, String title) implements Notification {}

public String send(Notification notification) {
    return switch (notification) {
        case EmailNotif e -> sendEmail(e.to(), e.subject(), e.body());
        case SmsNotif s   -> sendSms(s.phone(), s.message());
        case PushNotif p  -> sendPush(p.deviceToken(), p.title());
        // no default needed: all permitted subtypes covered
    };
}

If you later add a SlackNotif to the sealed interface, every switch over Notification will fail to compile until you add the new case. This is compile-time safety that you cannot get with default.

Record deconstruction patterns

Java 21 also supports record patterns that destructure records directly in the switch:

public record Point(double x, double y) {}
public record Line(Point start, Point end) {}

public String describeShape(Object shape) {
    return switch (shape) {
        case Point(var x, var y) ->
            "Point at (" + x + ", " + y + ")";

        case Line(Point(var x1, var y1), Point(var x2, var y2)) ->
            "Line from (" + x1 + "," + y1 + ") to (" + x2 + "," + y2 + ")";

        default -> "Unknown shape";
    };
}

Nested record patterns let you reach into deeply nested structures in a single case clause.

Practical example: parsing command results

Here is a realistic example combining sealed interfaces, records, and pattern matching:

public sealed interface CommandResult {
    record Ok(String output) implements CommandResult {}
    record Error(int code, String message) implements CommandResult {}
    record Timeout(Duration elapsed) implements CommandResult {}
}

public void handleResult(CommandResult result) {
    switch (result) {
        case CommandResult.Ok(var output) -> {
            logger.info("Command succeeded");
            processOutput(output);
        }
        case CommandResult.Error(var code, var msg) when code >= 500 -> {
            logger.error("Server error {}: {}", code, msg);
            alertOncall(code, msg);
        }
        case CommandResult.Error(var code, var msg) -> {
            logger.warn("Client error {}: {}", code, msg);
        }
        case CommandResult.Timeout(var elapsed) -> {
            logger.error("Command timed out after {}", elapsed);
            retryCommand();
        }
    }
}

Migration tips

  1. Start with instanceof chains: Search for if (x instanceof Foo) patterns and convert them to switch expressions.
  2. Introduce sealed interfaces gradually: Convert existing class hierarchies to sealed interfaces when you want exhaustiveness guarantees.
  3. Avoid default on sealed types: Using default defeats the purpose of sealed hierarchies. Let the compiler enforce completeness.
  4. Order cases from specific to general: Guarded patterns before unguarded patterns, subtypes before supertypes.
  5. Use record deconstruction for DTOs: When working with nested data records, deconstruction patterns reduce boilerplate significantly.

Pattern matching in switch is not syntactic sugar. It changes how you structure code: from imperative if-else chains to declarative, compiler-verified branching. Combined with sealed types, it makes entire categories of bugs impossible.