Java - Pattern Matching for Switch Examples

This tutorial is about pattern matching for the switch statement in Java, which was first introduced in Java 17 as a preview feature.

Switch statement has been available in Java for a long time. Unfortunately, it was very limited. Before Java 17, switch only supported numeric types, enum types, and String. In addition, you can only test for exact equality against constants. Since Java 17, switch has a new feature called pattern matching which allows more flexibility for defining the condition for each case.

Using Pattern Matching for Switch

Below I am going to explain what  you can do inside a switch block with the addition of the pattern matching for switch feature.

Type Patterns

Let's say you want to create a method for formatting a variable based on its type. Since the variable can have any type, the parameter has to use Object type. However, before Java 17, switch only supported certain data types and it didn't support pattern matching for checking the type. To achieve the solution, you need to use an if...else statement with multiple conditions.

  static String formatValue(Object o) {
    String result = "";

    if (o instanceof Double) {
      result = String.format("Double value is %f", o);
    } else if (o instanceof Integer) {
      result = String.format("Integer value is %d", o);
    } else if (o instanceof Long) {
      result = String.format("Long value is %d", o);
    } else if (o instanceof String) {
      result = String.format("String value is %s", o);
    } else {
      result = o.toString();
    }

    return result;
  }

The above approach works as expected. However, it has some disadvantages. First, it's prone to coding errors. If you forget to assign the formatted value to the result variable, the compiler will not be able to identify and verify that. Another disadvantage is the time complexity of O(n) even though the problem can be solved in O(1).

With the pattern matching for switch feature, it becomes possible for case labels to use pattern. The code above can be rewritten to the code below. To define a type pattern in a case label, you need to write the type that's expected for the case label followed by a variable. In the respective block, you can access the variable without the need to cast the type.

  static String formatValue(Object o) {
    return switch (o) {
      case Double d  -> String.format("Double value is %f", d);
      case Integer i -> String.format("Integer value is %d", i);
      case Long l    -> String.format("Long value is %d", l);
      case String s  -> String.format("String value is %s", s);
      default        -> o.toString();
    };
  }

Guarded Patterns

If a value matches a particular type, sometimes you may need to check the passed value. For example, you want to format the passed value if it is a string whose length is greater than or equal to 1. Otherwise, if the value is a string whose length is 0, 'Empty String' will be returned.

  static String formatNonEmptyString(Object o) {
    switch (o) {
      case String s:
        if (s.length() >= 1) {
          return String.format("String value is %s", s);
        } else {
          return "Empty string";
        }
      default:
        return o.toString();
    }
  }

The solution above splits the logic to a case label (for checking the type) and an if statement (for checking the length). If you don't link that style, it can be solved by using guarded pattern. A guarded pattern is of the form p && e, where p is a pattern and e is a boolean expression. With guarded patterns, the conditional logic can be moved to the case label. The code above can be rewritten to the following

  static String formatNonEmptyString(Object o) {
    return switch (o) {
      case String s && s.length() >= 1  -> String.format("String value is %s", s);
      case String s                     -> String.format("Empty string");
      default                           -> o.toString();
    };
  }

Parenthesized Pattern

There is another pattern called parenthesized pattern. A parenthesized pattern is of the form (p), where p is a pattern. You may already be familiar with the usage of parentheses in Java. The pattern matching for switch feature also allows you to use parentheses in case labels.

For example, we want to create a case label which evaluates to true if the given value is a String whose length is at least two and contains either ! or @. Without parentheses, it may cause ambiguity and wrong execution order.

  static String formatValidString(Object o) {
    return switch (o) {
      case String s && s.length() >= 2 && s.contains("@") || s.contains("!")  -> String.format("Valid string value is %s", s);
      default                                                                 -> "Invalid value";
    };
  }

  public static void main(String[] args) {
    System.out.println(formatValidString("xx")); // Invalid value
    System.out.println(formatValidString("!")); // Valid string value is !
    System.out.println(formatValidString("@@")); // Valid string value is @@
  }

The code above returns the wrong value for the ! value because the && operator is evaluated first. With parenthesized pattern, you can add parentheses around s.contains("@") || s.contains("!"), so that it will be evaluated first.

  static String formatValidString(Object o) {
    return switch (o) {
      case String s && s.length() >= 2 && (s.contains("@") || s.contains("!"))  -> String.format("Valid string value is %s", s);
      default                                                                   -> "Invalid value";
    };
  }

  public static void main(String[] args) {
    System.out.println(formatValidString("xx")); // Invalid value
    System.out.println(formatValidString("!")); // Invalid value
    System.out.println(formatValidString("@@")); // Valid string value is @@
  }

Null Values Handling

Formerly, if a null value is passed to a switch statement, a NullPointerException will be thrown. That's because switch only supported a few reference types.

  static void testNullAndSpecialValues(String s) {
    if (s == null) {
      System.out.println("Value is null");
      return;
    }

    switch (s) {
      case "Woolha", "Java" -> System.out.println("Special value");
      default               -> System.out.println("Other value");
    }
  }

  public static void main(String[] args) {
    testNullAndSpecialValues(null); // Value is null
    testNullAndSpecialValues("Woolha"); // Special value
    testNullAndSpecialValues("Foo"); // Other value
  }

With the support of selector expression of any type and type patterns in case labels, it becomes possible to move the null checking into the switch.

  static void testNullAndSpecialValues(String s) {
    switch (s) {
      case null             -> System.out.println("Value is null");
      case "Woolha", "Java" -> System.out.println("Special value");
      default               -> System.out.println("Other value");
    }
  }

  public static void main(String[] args) {
    testNullAndSpecialValues(null); // Value is null
    testNullAndSpecialValues("Woolha"); // Special value
    testNullAndSpecialValues("Foo"); // Other value
  }

Completeness of pattern labels

If you create a switch expression, you have to handle all possible values. In conventional switch expressions, you can add some conditions in the switch block in order to cover all possible values. For pattern matching switch expressions, it's a bit different. If you define a switch with pattern matching, Java will check the type coverage. The case labels (including default) are required to include the type of the selector expression.

You can take a look at the example below. The switch accepts a parameter whose type is Object. However, there is only a case label for handling the case where the passed value is a String. There is no default label as well.

  static String formatStringValue(Object o) {
    return switch (o) {
      case String s -> String.format("String value is %s", s);
    };
  }

  public static void main(String[] args) {
    System.out.println(formatStringValue("test"));
  }

As a result, the following error is thrown.

  SwitchPatternMatching.java:125: error: the switch expression does not cover all possible input values
      return switch (o) {
             ^

The solution is you have to ensure that the case labels cover all possible values. You can also add the default label at the bottom.

  static String formatStringValue(Object o) {
    return switch (o) {
      case String s -> String.format("String value is %s", s);
      default       -> o.toString();
    };
  }

  public static void main(String[] args) {
    System.out.println(formatStringValue("test"));
  }

Dominance of Pattern Labels

In the example below, there are two switch labels. The first one evaluates to true if the passed value is a CharSequence. The second one evaluates to true if the passed value is a String. Because String is a subclass of CharSequence and the case label for String is put below the one for CharSequence, there is no chance that the execution goes to the second case label.

  static void printLength(Object o) {
    switch(o) {
        case CharSequence cs ->
            System.out.println("Sequence with length: " + cs.length());
        case String s ->
            System.out.println("String with length: " + s.length());
        default -> System.out.println("Unknown type");
    }
  }

  public static void main(String[] args) {
    printLength("woolha");
  }

The good thing is if you inadvertently make a mistake like in the code above, you'll get a compile time error.

  SwitchPatternMatching.java:144: error: this case label is dominated by a preceding case label
          case String s ->

Below is the fix for code above. It's valid because if the passed value is a non-String CharSequence (StringBuilder or StringBuffer), the code block of the second case label will be executed.

  static void printLength(Object o) {
    switch(o) {
      case String s ->
          System.out.println("String with length: " + s.length());
      case CharSequence cs ->
          System.out.println("Sequence with length: " + cs.length());
      default -> System.out.println("Unknown type");
    }
  }

  public static void main(String[] args) {
    printLength("woolha");
  }

Summary

With the addition of pattern matching for the switch statement in Java, you can do more things inside switch statements. It enables you to perform type matching for any type, added with the support of guarded pattern and parenthesized pattern. Null values can be handled as well. In addition, the capability to check the completeness and dominance of pattern labels reduces the possibility of coding error.