1. Overview
A HashMap stores key-value mappings. In this tutorial, we’ll discuss how to store values of different types in a HashMap.
2. Introduction to the Problem
Since the introduction of Java Generics, we’ve typically used HashMap in a generic way – for example:
Map<String, Integer> numberByName = new HashMap<>();
In this case, we can only put String and Integer data as key-value pairs into the map numberByName. That’s good, as it ensures type safety. For example, if we attempt to put a Float object into the Map, we’ll get the “incompatible types” compilation error.
However, sometimes, we would like to put data of different types into a Map. For example, we want the numberByName map to store Float and BigDecimal objects as values as well.
Before discussing how to achieve that, let’s create an example problem to make the demonstration and explanation easier. Let’s say we have three objects of different types:
Integer intValue = 777;
int[] intArray = new int[]{2, 3, 5, 7, 11, 13};
Instant instant = Instant.now();
As we can see, the three types are entirely different. So first, we’ll try to put these three objects in a HashMap. To make it simple, we’ll use String values as keys.
Of course, at some point, we need to read the data out from the Map and use the data. Therefore, we’ll walk through the entries in the HashMap, and for each entry, we print the value with some description.
So, let’s see how we can achieve that.
3. Using Map<String, Object>
We know that in Java, Object is the supertype of all types. Therefore, if we declare a Map as Map<String, Object>, it should accept values of any type.
Next, let’s see if this approach meets our requirements.
3.1. Putting Data Into the Map
As we’ve mentioned earlier, a Map<String, Object> allows us to put values of any type in it:
Map<String, Object> rawMap = new HashMap<>();
rawMap.put("E1 (Integer)", intValue);
rawMap.put("E2 (IntArray)", intArray);
rawMap.put("E3 (Instant)", instant);
It’s pretty straightforward. Next, let’s visit the entries in the Map and print the value and description.
3.2. Using the Data
After we put a value in the Map<String, Object>, we’ve lost the value’s concrete type. Therefore, we need to check and cast the value to the proper type before using the data. For instance, we can use the instanceof operator to verify a value’s type:
rawMap.forEach((k, v) -> {
if (v instanceof Integer) {
Integer theV = (Integer) v;
System.out.println(k + " -> "
+ String.format("The value is a %s integer: %d", theV > 0 ? "positive" : "negative", theV));
} else if (v instanceof int[]) {
int[] theV = (int[]) v;
System.out.println(k + " -> "
+ String.format("The value is an array of %d integers: %s", theV.length, Arrays.toString(theV)));
} else if (v instanceof Instant) {
Instant theV = (Instant) v;
System.out.println(k + " -> "
+ String.format("The value is an instant: %s", FORMATTER.format(theV)));
} else {
throw new IllegalStateException("Unknown Type Found.");
}
});
If we execute the code above, we’ll see the output:
E1 (Integer) -> The value is a positive integer: 777
E2 (IntArray) -> The value is an array of 6 integers: [2, 3, 5, 7, 11, 13]
E3 (Instant) -> The value is an instant: 2021-11-23 21:48:02
This approach works as we expected.
However, it has some disadvantages. Next, let’s take a closer look at them.
3.3. Disadvantages
First, if we’ve planned to let the map support relatively more different types, the multiple if-else statements will become a large code block and make the code difficult to read.
Moreover, if the types we want to use contain inheritance relationships, the instanceof check may fail.
For example, if we put a java.lang.Integer intValue and a java.lang.Number numberValue in the map, we cannot distinguish them using the instanceof operator. This is because both (intValue instanceof Integer) and (intValue instanceof Number) return true.
Therefore, we must add extra checks to determine a value’s concrete type. But, of course, this will make the code difficult to read.
Finally, since our map accepts values of any type, we’ve lost the type safety. That is to say, we have to handle the exception when unexpected types are encountered.
A question may come up: Is there a way to accept different types’ data and preserve the type safety?
So next, we’ll address another approach to solve the problem.
4. Creating a Supertype for All Required Types
In this section, we’ll introduce a supertype to preserve type safety.
4.1. Data Model
First, we create an interface DynamicTypeValue:
public interface DynamicTypeValue {
String valueDescription();
}
This interface will be the supertype of all types we expect the map to support. It can also contain some common operations. For example, we’ve defined a method valueDescription.
Then**, we create a class for each concrete type to wrap the value and implement the interface** we’ve created. For example, we can create an IntegerTypeValue class for the Integer type:
public class IntegerTypeValue implements DynamicTypeValue {
private Integer value;
public IntegerTypeValue(Integer value) {
this.value = value;
}
@Override
public String valueDescription() {
if(value == null){
return "The value is null.";
}
return String.format("The value is a %s integer: %d", value > 0 ? "positive" : "negative", value);
}
}
Similarly, let’s create classes for the other two types:
public class IntArrayTypeValue implements DynamicTypeValue {
private int[] value;
public IntArrayTypeValue(int[] value) { ... }
@Override
public String valueDescription() {
// null handling omitted
return String.format("The value is an array of %d integers: %s", value.length, Arrays.toString(value));
}
}
public class InstantTypeValue implements DynamicTypeValue {
private static DateTimeFormatter FORMATTER = ...
private Instant value;
public InstantTypeValue(Instant value) { ... }
@Override
public String valueDescription() {
// null handling omitted
return String.format("The value is an instant: %s", FORMATTER.format(value));
}
}
If we need to support more types, we just add corresponding classes.
Next, let’s look at how to use the data model above to store and use different types’ values in a map.
4.2. Putting and Using the Data in the Map
First, let’s see how to declare the Map and put various types’ data in it:
Map<String, DynamicTypeValue> theMap = new HashMap<>();
theMap.put("E1 (Integer)", new IntegerTypeValue(intValue));
theMap.put("E2 (IntArray)", new IntArrayTypeValue(intArray));
theMap.put("E3 (Instant)", new InstantTypeValue(instant));
As we can see, we’ve declared the map as Map<String, DynamicTypeValue> so that the type safety is guaranteed: Only data with the DynamicTypeValue type are allowed to be put into the map.
When we add data to the map, we instantiate the corresponding class we’ve created.
When we use the data, type checking and casting are not required:
theMap.forEach((k, v) -> System.out.println(k + " -> " + v.valueDescription()));
If we run the code, it’ll print:
E1 (Integer) -> The value is a positive integer: 777
E2 (IntArray) -> The value is an array of 5 integers: [2, 3, 5, 7, 11]
E3 (Instant) -> The value is an instant: 2021-11-23 22:32:43
As we can see, the code for this approach is clean and much easier to read.
Further, since we create a wrapper class for every type we need to support, types with inheritance relationships won’t lead to any problem.
Thanks to the type safety, we don’t need to handle the error case of facing the data of unexpected types.
5. Conclusion
In this article, we’ve discussed how to make a Java HashMap support different types’ value data.
Also, we’ve addressed two approaches to achieve it through examples.
As always, the source code that accompanies the article is available over on GitHub.