Maps with Variable Value Types

About Maps and Generics

Since Java introduced Generics, Maps became a bit safer to use.

Before generics, this code would pass compilation without issues:

Map fruitColours = new HashMap();
fruitColours.put(Fruit.banana, "blue");
/* In a method somewhere else */
if (fruitColour.get("banana") == Colour.blue) {
    throw new IllegalStateException("Alien blue bananas not supported");
}

We now have to specify the type of keys and the types, helping find one of the mistakes:

Map<Fruit,Colour>; fruitColours = new HashMap<>();
fruitColours.put(Fruit.banana, "blue"); // ← compilation error: Expected Colour, found String
/* In a method somewhere else */
if (fruitColour.get("banana") == Colour.blue) { // still compiles fine
    throw new IllegalStateException("Alien blue bananas not supported");
}

Unfortunately the wrong key type at line four is still not detected because Map.get takes an Object. I found two justifications for this apparent design flaw:

  • To support this kind of code:

    <T extends Eatable> void checkSomeColours(Map<T, Colour> colours) {
        if (fruitColour.get(Fruit.banana) == Colour.blue) {
            throw new IllegalStateException("Alien blue bananas not supported");
        }
    }

    You may pass fruitColours to that function, but if Map<K,V>.get required an argument of type K, then fruitColour.get(Fruit.banana), although correct, would cause a compilation error (expected T, found Fruit).

  • To support equal objects from different types:
    public enum Eatables {
        banana, rice, tomato;
        public boolean equals(Object o) {
            return ((o instanceof Enum) && this.name().equals(((Enum<?>)o).name();
        }
        public int hashCode() {
            return name().hashCode();
        }
    }
    /* ... */
    fruitColours.put(Fruit.banana, Colour.yellow);
    fruitColours.get(Eatables.banana); // → Colour.yellow
    

I have never met either of those situations but I suppose it may sort of makes sense.
This means however that you stand somewhere between Level Six and Level Seven when using the Map.get method (or containsKey, remove etc), so you may want to introduce your own Map type, subtype or wrapper if you care about it. This post mentions that IntelliJ produces a warning when seeing a get() with the wrong type, elevating Map.get from Level Six or Seven to Level Two!

Variable Value Types

On to today’s design example. After key types, a question about value types. How can we create a Map whose value types depend on key values? Specifically, suppose the key is an enumeration:

public enum Key {
    name, age, favouriteFruit
}

and we want to map name to a String, age to an Integer and favouriteFruit to a Fruit. We are basically forced to declare the Map like this:

Map<Key, Object> info = new HashMap<>();

The advantage of using a Map instead of an object with three attributes is that it is far easier to iterate through the attributes, which in my experience is a common need when the values all have a common supertype V: some parts of the application dealing with the map only care about properties in V and use the regular Map methods. Others need something else…

Level Seven/Six

Naive access has Level Seven at writing time, and Six at reading time:

map.put(Key.name, "John");
String name = (String)map.get(Key.name);

This is because we never said anything anywhere that name is supposed to map to a String.
Replace the String with an Integer in line one, and the map will still happily store your numerical name → Level Seven.
Replace instead the String cast with an Integer in line two, and you get is a runtime error → Level Six.

Level Six

We can’t unfortunately attach type parameters to enumeration elements. I’d love to do this:

/* This doesn't work! */
public enum Key<V> {
name<String>, age<Integer>, favouriteFruit<Fruit>
}

So we’ll have to content ourselves with this:

public enum Key {
    name(String.class),
    age(Integer.class),
    favouriteFruit(Fruit.class);
    public final Class<?> cls;
    private Key(Class<?> cls) { this.cls = cls; }
}

Storing the key type like that permits checking put() invocations at runtime:

public class TypeSafeMap extends HashMap<Key, Object> {
    /* ... */
    public Object put(Key key, Object value) {
        return super.put(key, key.cls.cast(value));
    }
    /* ... */
}

Now, map.put(Key.name, 42); still compiles without warnings, but throws a ClassCastException at runtime (expected String, got Integer). → Level Six.

Level One

We can’t put a type parameter on an enum, so let’s put the enum in a typed class!

public class TypedKey<T> {
    public final Key key;
    public static final TypedKey<String> name = new TypedKey<String>(Key.name);
    public static final TypedKey<Integer> age = new TypedKey<Integer>(Key.age);
    public static final TypedKey<Fruit> favouriteFruit = new TypedKey<Fruit>(Key.favouriteFruit);

    private TypedKey(Key key) {
        this.key = key;
    }
}

This is a way to use them in a HashMap subclass:

public class TypeSafeMap extends HashMap<Key, Object> {
    /* ... */
    public <T> T putTyped(TypedKey<T> key, T value) {
        return super.put(key.key, value);
    }
    public <T> T getTyped(TypedKey<T> key) {
        return super.get(key.key);
    }
    /* ... */
}

But it is probably safer to put them in a class that does not implement Map at all, to block access to the unsafe put and get methods. You may then provide an asMap method providing read-only access to a Map view of your class.
Assuming the latter, we can name the methods get and put, and go:

map.put(TypedKey.name, "John");
String age = map.get(TypedKey.age); // no cast needed!
map.put(TypedKey.name, 42); // compile-time error!
String favouriteFruit = (String)map.get(TypedKey.favouriteFruit); // compile time error!

Both reading and writing is now Level One-safe.