66. Dealing with completeness (type coverage) in pattern labels for switch
In a nutshell, switch
expressions and switch
statements that use null and/or pattern labels should be exhaustive. In other words, we must cover with explicit switch case labels all the possible values. Let’s consider the following example:
class Vehicle {}
class Car extends Vehicle {}
class Van extends Vehicle {}
private static String whatAmI(Vehicle vehicle) {
return switch(vehicle) {
case Car car -> "You're a car";
case Van van -> "You're a van";
};
}
This snippet of code doesn’t compile. The error is clear: The switch expression does not cover all possible input values. The compiler complains because we don’t have a case pattern label for Vehicle
. This base class can be legitimately used without being a Car
or a Van
, so it is a valid candidate for our switch
. We can add a case Vehicle
or a default
label. If you know that Vehicle
will remain an empty base class, then you’ll probably go for a default
label:
return switch(vehicle) {
case Car car -> "You're a car";
case Van van -> "You're a van";
default -> "I have no idea ... what are you?";
};
If we continue by adding another vehicle such as class Truck extends Vehicle {}
, then this will be handled by the default
branch. If we plan to use Vehicle
as an independent class (for instance, to enrich it with methods and functionalities), then we will prefer to add a case Vehicle
as follows:
return switch(vehicle) {
case Car car -> "You're a car";
case Van van -> "You're a van";
case Vehicle v -> "You're a vehicle"; // total pattern
};
This time, the Truck
class will match the case Vehicle
branch. Of course, we can add a case Truck
as well.
Important note
The Vehicle v
pattern is named a total type pattern. There are two labels that we can use to match all possible values: the total type pattern (for instance, a base class or an interface) and the default
label. Generally speaking, a total pattern is a pattern that can be used instead of the default
label.
In the previous example, we can accommodate all possible values via the total pattern or the default
label but not both. This makes sense since the whatAmI(Vehicle vehicle)
method gets Vehicle
as an argument. So, in this example, the selector expression can be only Vehicle
or a subclass of Vehicle
. How about modifying this method as whatAmI(Object o)
?
private static String whatAmI(Object o) {
return switch(o) {
case Car car -> "You're a car";
case Van van -> "You're a van";
case Vehicle v -> "You're a vehicle"; // optional
default -> "I have no idea ... what are you?";
};
}
Now, the selector expression can be any type, which means that the total pattern Vehicle v
is not total anymore. While Vehicle v
becomes an optional ordinary pattern, the new total pattern is case Object obj
. This means that we can cover all possible values by adding the default
label or the case Object obj
total pattern:
return switch(o) {
case Car car -> "You're a car";
case Van van -> "You're a van";
case Vehicle v -> "You're a vehicle"; // optional
case Object obj -> "You're an object"; // total pattern
};
I think you get the idea! How about using an interface for the base type? For instance, here is an example based on the Java built-in CharSequence
interface:
public static String whatAmI(CharSequence cs) {
return switch(cs) {
case String str -> "You're a string";
case Segment segment -> "You're a Segment";
case CharBuffer charbuffer -> "You're a CharBuffer";
case StringBuffer strbuffer -> "You're a StringBuffer";
case StringBuilder strbuilder -> "You're a StringBuilder";
};
}
This snippet of code doesn’t compile. The error is clear: The switch expression does not cover all possible input values. But, if we check the documentation of CharSequence
, we see that it is implemented by five classes: CharBuffer
, Segment
, String
, StringBuffer
, and StringBuilder
. In our code, each of these classes is covered by a pattern label, so we have covered all possible values, right? Well, yes and no… “Yes” because we cover all possible values for the moment, and “no” because anyone can implement the CharSequence
interface, which will break the exhaustive coverage of our switch
. We can do this:
public class CoolChar implements CharSequence { ... }
At this moment, the switch
expression doesn’t cover the CoolChar
type. So, we still need a default
label or the total pattern, case CharSequence charseq
, as follows:
return switch(cs) {
case String str -> "You're a string";
...
case StringBuilder strbuilder -> "You're a StringBuilder";
// we have created this
case CoolChar cool -> "Welcome ... you're a CoolChar";
// this is a total pattern
case CharSequence charseq -> "You're a CharSequence";
// can be used instead of the total pattern
// default -> "I have no idea ... what are you?";
};
Okay, let’s tackle this scenario on the java.lang.constant.ClassDesc
built-in interface:
private static String whatAmI(ConstantDesc constantDesc) {
return switch(constantDesc) {
case Integer i -> "You're an Integer";
case Long l -> "You're a Long";
case Float f -> " You're a Float";
case Double d -> "You're a Double";
case String s -> "You're a String";
case ClassDesc cd -> "You're a ClassDesc";
case DynamicConstantDesc dcd -> "You're a DCD";
case MethodHandleDesc mhd -> "You're a MethodHandleDesc";
case MethodTypeDesc mtd -> "You're a MethodTypeDesc";
};
}
This code compiles! There is no default
label and no total pattern but the switch
expression covers all possible values. How so?! This interface is declared as sealed via the sealed
modifier:
public sealed interface ClassDesc
extends ConstantDesc, TypeDescriptor.OfField<ClassDesc>
Sealed interfaces/classes were introduced in JDK 17 (JEP 409) and we will cover this topic in Chapter 8. However, for now, it is enough to know that sealing allows us to have fine-grained control of inheritance so classes and interfaces define their permitted subtypes. This means that the compiler can determine all possible values in a switch
expression. Let’s consider a simpler example that starts as follows:
sealed interface Player {}
final class Tennis implements Player {}
final class Football implements Player {}
final class Snooker implements Player {}
And, let’s have a switch
expression covering all possible values for Player
:
private static String trainPlayer(Player p) {
return switch (p) {
case Tennis t -> "Training the tennis player ..." + t;
case Football f -> "Training the football player ..." + f;
case Snooker s -> "Training the snooker player ..." + s;
};
}
The compiler is aware that the Player
interface has only three implementations and all of them are covered via pattern labels. We can add a default
label or the total pattern case Player player
, but you most probably don’t want to do that. Imagine that we add a new implementation of the sealed Player
interface named Golf
:
final class Golf implements Player {}
If the switch
expression has a default
label, then Golf
values will be handled by this default
branch. If we have the total pattern Player player
, then this pattern will handle the Golf
values. On the other hand, if none of the default
labels or total patterns are present, the compiler will immediately complain that the switch
expression doesn’t cover all possible values. So, we are immediately informed, and once we add a case Golf g
, the error disappears. This way, we can easily maintain our code and have a guarantee that our switch
expressions are always up to date and cover all possible values. The compiler will never miss the chance to inform us when a new implementation of Player
is available.
A similar logic applies to Java enums. Consider the following enum
:
private enum PlayerTypes { TENNIS, FOOTBALL, SNOOKER }
The compiler is aware of all the possible values for PlayerTypes
, so the following switch
expression compiles successfully:
private static String createPlayer(PlayerTypes p) {
return switch (p) {
case TENNIS -> "Creating a tennis player ...";
case FOOTBALL -> "Creating a football player ...";
case SNOOKER -> "Creating a snooker player ...";
};
}
Again, we can add a default
label or the total pattern, case PlayerTypes pt
. But, if we add a new value in the enum
(for instance, GOLF
), the compiler will delegate the default
label or the total pattern to handle it. On the other hand, if none of these are available, the compiler will immediately complain that the GOLF
value is not covered, so we can add it (case GOLF g
) and create a golf player whenever required.
So far, so good! Now, let’s consider the following context:
final static class PlayerClub implements Sport {};
private enum PlayerTypes implements Sport
{ TENNIS, FOOTBALL, SNOOKER }
sealed interface Sport permits PlayerTypes, PlayerClub {};
The sealed interface Sport
allows only two subtypes: PlayerClub
(a class) and PlayerTypes
(an enum). If we write a switch
that covers all possible values for Sport
, then it will look as follows:
private static String createPlayerOrClub(Sport s) {
return switch (s) {
case PlayerTypes p when p == PlayerTypes.TENNIS
-> "Creating a tennis player ...";
case PlayerTypes p when p == PlayerTypes.FOOTBALL
-> "Creating a football player ...";
case PlayerTypes p -> "Creating a snooker player ...";
case PlayerClub p -> "Creating a sport club ...";
};
}
We immediately observe that writing case PlayerTypes p when p == PlayerTypes.TENNIS
is not quite neat. What we actually want is case PlayerTypes.TENNIS
but, until JDK 21, this is not possible since qualified enum constants cannot be used in case
labels. However, starting with JDK 21, we can use qualified names of enum constants as labels, so we can write this:
private static String createPlayerOrClub(Sport s) {
return switch (s) {
case PlayerTypes.TENNIS
-> "Creating a tennis player ...";
case PlayerTypes.FOOTBALL
-> "Creating a football player ...";
case PlayerTypes.SNOOKER
-> "Creating a snooker player ...";
case PlayerClub p
-> "Creating a sport club ...";
};
}
Done! Now you know how to deal with type coverage in switch
expressions.