Career Guide

The Evolution of the Switch Statement in Modern Java (Expressions & Pattern Matching)

The switch statement has evolved from an error-prone, fall-through C-style statement to a modern, type-safe pattern matching expression. Learn how to write cleaner switch blocks in JDK 21+.

For decades, the switch statement in Java remained virtually unchanged from its C origins. While functional, it was notorious for being verbose, error-prone, and limited. Developers frequently encountered bugs caused by forgotten break statements, leading to accidental fall-through. Furthermore, classic switch could only check a limited set of types: primitives, wrapped primitives, enums, and strings.

Fortunately, Java has undergone a massive modernization effort. Starting with switch expressions in JDK 14 and culminating in pattern matching for switch and record patterns in JDK 21, the humble switch has been transformed into a powerful, type-safe control flow mechanism.

The Drawbacks of Classic Switch

To appreciate how far we have come, consider the classic switch statement:

int numLetters;
switch (day) {
    case MONDAY:
    case FRIDAY:
    case SUNDAY:
        numLetters = 6;
        break;
    case TUESDAY:
        numLetters = 7;
        break;
    default:
        numLetters = 8;
}

This code has several issues:

  1. Verbosity: We must write break for every branch.
  2. Accidental Fall-through: If we omit break, execution continues into the next case, creating subtle bugs.
  3. No Value Return: The switch is a statement, not an expression. We must declare a mutable variable outside the switch and assign it inside each branch.

Switch Expressions: Arrow Syntax and Yield

Java 14 solved these issues by introducing switch expressions. Using the arrow (->) syntax, we can write cleaner, non-fall-through code:

int numLetters = switch (day) {
    case MONDAY, FRIDAY, SUNDAY -> 6;
    case TUESDAY                -> 7;
    default                     -> 8;
};

Key improvements here:

  • No fall-through: The code to the right of -> is executed, and control immediately leaves the switch.
  • Value returning: The switch returns a value directly, allowing us to initialize variables in a single line.
  • Yield keyword: If a branch requires a multi-line block, we can use the yield keyword to return a value:
int numLetters = switch (day) {
    case MONDAY -> {
        System.out.println("Starting the week!");
        yield 6;
    }
    default -> 8;
};

Pattern Matching for Switch

In JDK 21, Java extended switch capability to support pattern matching. Instead of just switching on values, we can now switch on the type of an object. This removes the need for long if-else chains with instanceof checks.

public String formatObject(Object obj) {
    return switch (obj) {
        case Integer i -> String.format("Integer: %d", i);
        case Long l    -> String.format("Long: %d", l);
        case Double d  -> String.format("Double: %.2f", d);
        case String s  -> String.format("String: %s", s);
        default        -> obj.toString();
    };
}

Guarded Patterns: The when Clause

Sometimes, switching on type alone is not enough. You might want to filter values based on an additional condition. Java handles this using guarded patterns with the when clause:

public String formatString(Object obj) {
    return switch (obj) {
        case String s when s.isEmpty() -> "Empty string";
        case String s when s.length() > 100 -> "Very long string";
        case String s -> "Normal string: " + s;
        default -> "Not a string";
    };
}

The compiler evaluates cases sequentially, so more specific patterns (like checking length) should be placed above generic ones.

Record Patterns: Inline Destructuring

Java 21 also introduced record patterns, allowing you to destructure record variables directly inside the switch case.

Imagine a record representing a coordinate:

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

Instead of extracting x and y manually, you can destructure them inline:

public void printPoint(Object obj) {
    switch (obj) {
        case Point(int x, int y) -> System.out.printf("X: %d, Y: %d%n", x, y);
        case String s            -> System.out.println("String: " + s);
        default                  -> System.out.println("Unknown type");
    }
}

This makes deconstructing complex nested structures incredibly clean and readable.

Modern Null Handling in Switch

Historically, passing a null reference to a switch statement would immediately result in a NullPointerException (NPE). To avoid this, developers had to write defensive if (obj == null) checks before the switch block. This extra boilerplate cluttered the codebase.

In JDK 21, the switch statement has been updated to handle null values natively. You can now define a specific case null label directly inside the switch block.

Here is a code example showing modern null handling:

public String processInput(String input) {
    return switch (input) {
        case null          -> "Input was null";
        case "start"       -> "Beginning process...";
        case "stop", "end" -> "Terminating process...";
        default            -> "Unknown instruction: " + input;
    };
}

By adding case null, you explicitly define the behavior when the input is null, and the compiler prevents the runtime NPE. You can also combine case null with other cases using comma separation, such as case null, "default" -> .... This modern touch makes your switch statements far more robust and self-contained.

📢 Share this article