3.3 Enhanced Switch

Switch expressions represent one of the most significant syntactic improvements in modern Java, transforming switches from error-prone statements into safe, exhaustive expressions that integrate seamlessly with pattern matching.

From Statement to Expression

Traditional Switch Statements (Pre-Java 14)

int priority;
switch (status) {
    case CRITICAL:
        priority = 1;
        break;
    case HIGH:
        priority = 2;
        break;
    case MEDIUM:
        priority = 3;
        break;
    default:
        priority = 4;
}
return priority;

Problems:

  1. Mutable variable required (priority)
  2. Fall-through bugs if break is forgotten
  3. No exhaustiveness checking
  4. Verbose: 13 lines for simple mapping
  5. Not an expression: can't return directly or nest

Modern Switch Expressions (Java 14+)

return switch (status) {
    case CRITICAL -> 1;
    case HIGH -> 2;
    case MEDIUM -> 3;
    default -> 4;
};

Benefits:

  1. Immutable: returns directly
  2. Safe: arrow syntax prevents fall-through
  3. Concise: 5 lines vs 13
  4. Expression: can assign, return, pass as argument
  5. Exhaustiveness: compiler can verify completeness

Arrow Labels vs Colon Labels

Switch expressions support two syntaxes:

String result = switch (day) {
    case MONDAY, FRIDAY -> "Coding day";
    case TUESDAY, THURSDAY -> "Meeting day";
    case WEDNESDAY -> "Deep work";
    case SATURDAY, SUNDAY -> "Weekend";
};

No fall-through: Each case is independent, no break needed.

Colon Syntax (Legacy Compatibility)

String result = switch (day) {
    case MONDAY, FRIDAY:
        yield "Coding day";
    case TUESDAY, THURSDAY:
        yield "Meeting day";
    default:
        yield "Other";
};

Requires yield: Use yield to return a value (not return).

Prefer arrow syntax for new code—it's safer and more concise.

Exhaustiveness Checking

Switch expressions must be exhaustive: every possible value must be covered, or the code won't compile.

With Enums (No Default Needed)

enum Color { RED, GREEN, BLUE }

String toHex(Color color) {
    return switch (color) {
        case RED -> "#FF0000";
        case GREEN -> "#00FF00";
        case BLUE -> "#0000FF";
        // No default needed—all enum values covered
    };
}

If you add a new enum value (e.g., YELLOW), every switch must be updated—the compiler forces you to handle it.

With Sealed Types (Java 17+)

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(var r) -> Math.PI * r * r;
        case Rectangle(var w, var h) -> w * h;
        case Triangle(var b, var h) -> 0.5 * b * h;
        // No default needed—sealed hierarchy is closed
    };
}

Sealed types + switch expressions = exhaustiveness without default.

With Primitive Types (Default Required)

int prioritize(int code) {
    return switch (code) {
        case 1, 2, 3 -> "High";
        case 4, 5, 6 -> "Medium";
        default -> "Low";  // Required: int has too many values to enumerate
    };
}

Multiple Case Labels

Combine multiple cases with comma-separated values:

String dayType(Day day) {
    return switch (day) {
        case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "Weekday";
        case SATURDAY, SUNDAY -> "Weekend";
    };
}

Equivalent to OR logic: if (day == MONDAY || day == TUESDAY || ...).

Pattern Matching in Switch (Java 21+)

Switch can now match on types, records, and complex patterns—not just constants.

Type Patterns

Object obj = fetchData();

String result = switch (obj) {
    case null -> "null";
    case Integer i -> "Integer: " + i;
    case String s -> "String: " + s.toUpperCase();
    case List<?> list -> "List of size " + list.size();
    default -> "Unknown type";
};

Pattern variable i, s, list is in scope for that case only.

Record Patterns (Destructuring)

record Point(int x, int y) {}

String describe(Object obj) {
    return switch (obj) {
        case null -> "null";
        case Point(var x, var y) -> "Point at (" + x + ", " + y + ")";
        case String s -> "String: " + s;
        default -> "Unknown";
    };
}

Destructuring: Point(var x, var y) extracts fields directly—no getX() calls.

Nested Patterns

record Rectangle(Point topLeft, Point bottomRight) {}
record Point(int x, int y) {}

int width(Object obj) {
    return switch (obj) {
        case Rectangle(Point(var x1, var y1), Point(var x2, var y2)) -> 
            Math.abs(x2 - x1);
        default -> 0;
    };
}

Nested destructuring: Extracts x1, y1, x2, y2 from nested records in one pattern.

Guarded Patterns (when Clauses)

Add boolean conditions to patterns using when:

String classify(Object obj) {
    return switch (obj) {
        case String s when s.length() > 10 -> "Long string";
        case String s when s.isBlank() -> "Blank string";
        case String s -> "Regular string: " + s;
        case Integer i when i > 0 -> "Positive integer";
        case Integer i when i < 0 -> "Negative integer";
        case Integer i -> "Zero";
        default -> "Unknown";
    };
}

Guard evaluation: when clause evaluated only if pattern matches.

Pattern dominance: More specific patterns (with guards) must come before general patterns:

// CORRECT: Specific before general
case String s when s.length() > 10 -> "Long";
case String s -> "Other";

// COMPILE ERROR: Unreachable pattern
case String s -> "Other";
case String s when s.length() > 10 -> "Long";  // Never reached!

Null Handling

Switch expressions can explicitly handle null:

String process(String input) {
    return switch (input) {
        case null -> "null input";
        case "" -> "empty";
        case String s -> "Value: " + s;
    };
}

Before Java 21: Switch threw NullPointerException if input was null.

Java 21+: case null handles null explicitly.

Combining null with other patterns:

case null, String s when s.isBlank() -> "Empty or blank";

Yielding Values from Blocks

For complex logic, use blocks with yield:

int compute(int value) {
    return switch (value) {
        case 1, 2, 3 -> {
            System.out.println("Low value");
            yield value * 10;
        }
        case 4, 5, 6 -> {
            System.out.println("Mid value");
            yield value * 100;
        }
        default -> {
            System.out.println("High value");
            yield value * 1000;
        }
    };
}

yield vs return:

  • yield returns a value from the switch expression
  • return exits the entire method

Sealed Types for Exhaustiveness

Combine sealed types with switch for compile-time completeness guarantees:

sealed interface Result<T> permits Success, Failure {}
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}

<T> String format(Result<T> result) {
    return switch (result) {
        case Success<T>(var value) -> "Success: " + value;
        case Failure<T>(var error) -> "Error: " + error;
        // No default needed—sealed hierarchy is closed
    };
}

Adding a new implementation (e.g., Pending) forces updates to all switches.

Practical Examples

Example 1: Command Handler

sealed interface Command permits Create, Update, Delete {}
record Create(String name, String email) implements Command {}
record Update(String id, String field, String value) implements Command {}
record Delete(String id) implements Command {}

String handle(Command cmd) {
    return switch (cmd) {
        case Create(var name, var email) when !email.isBlank() ->
            """
            {"action":"create","name":"%s","email":"%s"}
            """.formatted(name, email);

        case Create(var name, _) ->
            """
            {"error":"Email required"}
            """;

        case Update(var id, var field, var value) ->
            """
            {"action":"update","id":"%s","field":"%s","value":"%s"}
            """.formatted(id, field, value);

        case Delete(var id) ->
            """
            {"action":"delete","id":"%s"}
            """.formatted(id);
    };
}

Pattern destructuring + guards = concise, safe command handling.

Example 2: HTTP Status Code Handler

String handleStatus(int status) {
    return switch (status) {
        case 200, 201, 204 -> "Success";
        case 301, 302, 307, 308 -> "Redirect";
        case 400, 404 -> "Client error";
        case 401, 403 -> "Authentication/Authorization error";
        case 500, 502, 503, 504 -> "Server error";
        default -> "Unknown status: " + status;
    };
}

Example 3: Nested Record Parsing

sealed interface Expr permits Const, Add, Mult {}
record Const(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mult(Expr left, Expr right) implements Expr {}

int evaluate(Expr expr) {
    return switch (expr) {
        case Const(var value) -> value;
        case Add(var left, var right) -> evaluate(left) + evaluate(right);
        case Mult(var left, var right) -> evaluate(left) * evaluate(right);
    };
}

// Usage
Expr expr = new Add(
    new Const(10),
    new Mult(new Const(5), new Const(3))
);
int result = evaluate(expr);  // 10 + (5 * 3) = 25

Recursive evaluation with pattern matching—elegant expression interpreter.

Pattern Dominance and Ordering

Patterns must be ordered from most specific to least specific:

// CORRECT: Specific patterns first
switch (obj) {
    case String s when s.length() > 100 -> "Very long";
    case String s when s.length() > 10 -> "Long";
    case String s -> "Short";
    default -> "Not a string";
}

// COMPILE ERROR: Unreachable pattern
switch (obj) {
    case String s -> "Any string";  // Catches all strings
    case String s when s.length() > 10 -> "Long";  // Never reached!
    default -> "Not a string";
}

Rule: If pattern A matches all cases that pattern B matches, A dominates B and must come after.

Common Pitfalls

Pitfall 1: Forgetting yield with Colon Syntax

// ERROR: Missing yield
int result = switch (x) {
    case 1: 10;  // Should be "yield 10;"
    default: 20;
};

// CORRECT: Use arrow syntax
int result = switch (x) {
    case 1 -> 10;
    default -> 20;
};

Pitfall 2: Exhaustiveness with Sealed Types

sealed interface Shape permits Circle, Square {}

// COMPILE ERROR if you add Triangle later
double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Square s -> s.side() * s.side();
        // Missing Triangle case!
    };
}

Solution: Compiler forces you to handle new types—prevents runtime errors.

Pitfall 3: Pattern Variable Scope

switch (obj) {
    case String s -> {
        System.out.println(s.length());  // OK: s in scope
    }
    case Integer i -> {
        System.out.println(s.length());  // ERROR: s not in scope here
    }
}

Pattern variables are case-scoped, not switch-scoped.

Pitfall 4: Null Handling

// Before Java 21: NullPointerException if obj is null
switch (obj) {
    case String s -> process(s);
    default -> handleOther(obj);
}

// Java 21+: Explicit null handling
switch (obj) {
    case null -> handleNull();
    case String s -> process(s);
    default -> handleOther(obj);
}

Performance Considerations

Switch expressions have no performance penalty:

  • Compiled to tableswitch (for dense ranges) or lookupswitch (for sparse values)
  • Pattern matching uses same instanceof checks as manual code
  • Exhaustiveness is compile-time—no runtime overhead

In fact, switch expressions often improve performance:

  • JIT can optimize immutable returns better than mutable variables
  • Exhaustive switches allow more aggressive compiler optimizations

Best Practices

  1. Use arrow syntax for new code (safer, more concise)
  2. Leverage exhaustiveness with enums and sealed types (no default needed)
  3. Order patterns from specific to general (more specific first)
  4. Use guards for complex conditions instead of if inside case
  5. Handle null explicitly when possible (Java 21+)
  6. Destructure records to avoid getter calls
  7. Prefer expressions over statements (assign directly, return directly)
  8. Keep cases simple (extract complex logic to methods)

Comparison: Switch Statement vs Expression

Feature Statement Expression
Returns value No (must assign to variable) Yes (use directly)
Fall-through Yes (requires break) No (arrow syntax)
Exhaustiveness No Yes (compiler-enforced)
Mutable variable Required Not needed
Pattern matching Limited (Java 21+) Full support (Java 21+)
Null handling NullPointerException Explicit case null

Migration Strategy

Convert switch statements to expressions gradually:

Step 1: Convert to expression form

return switch (status) {
    case ACTIVE: yield "active";
    case INACTIVE: yield "inactive";
    default: yield "unknown";
};

Step 2: Use arrow syntax

return switch (status) {
    case ACTIVE -> "active";
    case INACTIVE -> "inactive";
    default -> "unknown";
};

Step 3: Add pattern matching (if applicable)

return switch (obj) {
    case Status s when s == ACTIVE -> "active";
    case Status s -> "other";
    default -> "unknown";
};

Conclusion

Switch expressions transform switches from error-prone statements into safe, expressive tools:

  • Exhaustiveness prevents missing cases
  • Pattern matching eliminates boilerplate
  • Immutability by default reduces bugs
  • Type safety maintained throughout

Combined with sealed types and record patterns, modern switch expressions enable safe, concise polymorphism that rivals pattern matching in functional languages while maintaining Java's static typing guarantees.

Next section: Text Blocks and String Templates—mastering multi-line strings and formatting.