1. Introduction
We often make use of maps in our programs, as a means to associate keys with values. Typically in our Java programs, especially since the introduction of generics, we will have all of the keys be the same type and all of the values be the same type. For example, a map of IDs to values in a data store.
On some occasions, we might want to use a map where the keys are not always the same type. For example, if we change our ID types from Long to String, then our data store will need to support both key types – Long for the old entries and String for the new ones.
Unfortunately, the Java Map interface doesn’t allow for multiple key types, so we need to find another solution. We’re going to explore a few ways this can be achieved in this article.
2. Using Generic Supertypes
The easiest way to achieve this is to have a map where the key type is the closest supertype to all of our keys. In some cases, this might be easy – for example, if our keys are Long and Double then the closest supertype is Number:
Map<Number, User> users = new HashMap<>();
users.get(longId);
users.get(doubleId);
However, in other cases, the closest supertype is Object. This has the downside that it completely removes type safety from our map:
Map<Object, User> users = new HashMap<>();
users.get(longId); /// Works.
users.get(stringId); // Works.
users.get(Instant.now()); // Also works.
In this case, the compiler doesn’t stop us from passing the wrong types in, effectively removing all type safety from our map. In some cases, this might be fine. For example, this will probably be fine if another class encapsulates the map so as to enforce the type safety itself.
However, it still opens up risks in how the map can be used.
3. Multiple Maps
If type safety is important, and we’ll be encapsulating our map inside another class, another simple option is to have multiple maps. In this case, we’d have a different map for each of our supported keys:
Map<Long, User> usersByLong = new HashMap<>();
Map<String, User> usersByString = new HashMap<>();
Doing this ensures that the compiler will keep type safety for us. If we try to use an Instant here, then the compiler won’t let us, so we’re safe here.
Unfortunately, this adds complexity because we need to know which of our maps to use. This means that we either have different methods working with different maps, or else we’re doing type checking everywhere.
This also doesn’t scale well. We will need to add a new map and new checks all over if we ever need to add a new key type. For two or three key types, this is manageable, but it quickly gets to be too much.
4. Key Wrapper Types
If we need to have type safety, and we don’t want the maintainability burden of many maps, then we need to find a way to have a single map that can have different values in the key. This means that we need to find some way to have a single type that is actually different types. We can achieve this in two different ways – with a single wrapper or with an interface and subclasses.
4.1. Single Wrapper Class
One option we have is to write a single class that can wrap any of our possible key types. This will have a single field for the actual key value, correct equals and hashCode methods, and then one constructor for each possible type:
class MultiKeyWrapper {
private final Object key;
MultiKeyWrapper(Long key) {
this.key = key;
}
MultiKeyWrapper(String key) {
this.key = key;
}
@Override
public bool equals(Object other) { ... }
@Override
public int hashCode() { ... }
}
This is guaranteed to be typesafe because it can only be constructed with either a Long or a String. And we can use it as a single type in our map because it is in itself a single class:
Map<MultiKeyWrapper, User> users = new HashMap<>();
users.get(new MultiKeyWrapper(longId)); // Works
users.get(new MultiKeyWrapper(stringId)); // Works
users.get(new MultiKeyWrapper(Instant.now())); // Compilation error
We simply need to wrap our Long or String in our new MultiKeyWrapper for every access to the map.
This is relatively simple, but it will make extension slightly harder. Whenever we want to support any additional types then, we’ll need to change our MultiKeyWrapper class to support it.
4.2. Interface and Subclasses
Another alternative is to write an interface to represent our key wrapper and then write an implementation of this interface for every type that we want to support:
interface MultiKeyWrapper {}
record LongMultiKeyWrapper(Long value) implements MultiKeyWrapper {}
record StringMultiKeyWrapper(String value) implements MultiKeyWrapper {}
As we can see, these implementations can use the Record functionality introduced in Java 14, which will make the implementation much easier.
As before, we can then use our MultiKeyWrapper as the single key type for a map. We then use the appropriate implementation for the key type that we want to use:
Map<MultiKeyWrapper, User> users = new HashMap<>();
users.get(new LongMultiKeyWrapper(longId)); // Works
users.get(new StringMultiKeyWrapper(stringId)); // Works
In this case, we don’t have a type to use for anything else, so we can’t even write invalid code in the first place.
With this solution, we support additional key types not by changing the existing classes but by writing a new one. This is easier to support, but it also means that we have less control over what key types are supported.
However, this can be managed by the correct use of visibility modifiers. Classes can only implement our interface if they have access to it, so if we make it package-private, then only classes in the same package can implement it.
5. Conclusion
Here we’ve seen some ways to represent a map of keys to values, but where the keys are not always of the same type. Examples of these strategies can be found over on GitHub.