Towards a Typesafe Map

Keywords: #Java

Say we want a heterogeneous Map in java, i.e. a map where the values have different types. Let’s further assume we know the possible keys at compile time.

Barebones solution

The simplest approach would be having a Map<String, Object> (or whatever other key type). We would need to remember which key corresponds to which type, and do the proper casts:

String value1 = (String) map.get("someStringKey");
boolean value2 = (boolean) map.get("someBooleanKey");
/* etc. */

This is of course error-prone, delegating to the programmer what should be the task of the compiler.

Introducing generics: classes as keys

A better option is relying on generics for type safety. One such option is found for instance in Joshua Bloch’s Effective Java, Item 33 (Consider typesafe heterogeneous containers). This looks as follows:

public class HeterogeneousMap {
	private Map<Class<?>, Object> map = new HashMap<>();

	public <T> void put(Class<T> key, T value) {
		map.put(key, value);
	}
	public <T> T get(Class<T> key) {
		return key.cast(map.get(key));
	}
}

With this, we can operate with the map as follows:

HeterogeneousMap map = new HeterogeneousMap();
map.put(Integer.class, 5);
map.put(String.class, "High");
System.out.println(map.get(String.class) + map.get(Integer.class)) // prints: High 5

This has the limitation that we can have at most one value of each class.

Adding a polymorphic key type

To surmount the previous limitation, we can define a type to act as key which has one type argument. We can then declare the different instances as public constants, as a kind of handrolled enum:

public class Key<T> {
	public static final Key<Integer> KEY1 = new Key(Integer.class);
	public static final Key<Integer> KEY2 = new Key(Integer.class);
	public static final Key<String> KEY3 = new Key(String.class);
	/* etc. */

	private Key(Class<T> v) { }
}

Note that the type as such is a phantom type1, though it needen’t be, as we could store the class or other values if so desired. With such defintion, our map implementation would be:

public class HeterogeneousMap {
	private Map<Key<?>, Object> map = new HashMap<>();

	public <T> void put(Key<T> key, T value) {
		map.put(key, value);
	}
	public <T> T get(Key<T> key) {
		return key.cast(map.get(key));
	}
}

With such setup, we now can have more than one value with the same type, though we rely on knowing the different keys at compile-time.

Abstracting further: simulating higher-kinded types

Now suppose we want to use instances of this map in different contexts with different keys. For instance, we could have keys that represent user options:

public class UserOption<T> {
	public static final UserOption<String> TIMEZONE = create(String.class);
	public static final UserOption<String> LANGUAGE = create(String.class);
	public static final UserOption<WebsiteTheme> THEME = create(WebsiteTheme.class);
	/* etc. */

	private UserOption(Class<T> v) { }

	private static <V> UserOption<V> create(Class<V> c) {
		return new UserOption<>(c);
	}
}

and some other keys that represent optional parameters to some service method:

public class OptionalParameter<T> {
	public static final OptionalParameter<String> SOME_PARAM = create(String.class);
	public static final OptionalParameter<Long> ANOTHER_PARAM = create(Long.class);
	/* etc. */

	private OptionalParameter(Class<T> v) { }

	private static <V> OptionalParameter<V> create(Class<V> c) {
		return new OptionalParameter<>(c);
	}
}

The problem with this is that we need HeterogeneousMap to be generic on the type of key, something like the following:

public class HeterogeneousMap<K> {
	private Map<K<?>, Object> map = new HashMap<>();

	public <T> void put(K<T> key, T value) {
		map.put(key, value);
	}
	public <T> T get(K<T> key) {
		return key.cast(map.get(key));
	}
}

This would then allow us to define a HeterogeneousMap<OptionalParameter<?>>, HeterogeneousMap<UserOption<?>>, etc. Unfortunately this doesn’t compile in java. Java doesn’t have higher-kinded types 2, which is precisely what we need for this. We can simulate the feature up to a point as follows:

/* Marker interface for encoding a type F<T> of kind * -> *. */
public interface H<F,T> {}
public class OptionalParameter<T> implements H<OptionalParameter<?>, T> {
	/* the rest is the same as before */
}
public class UserOption<T> implements H<UserOption<?>, T> {
	/* the rest is the same as before */
}
public class HeterogeneousMap<S extends H<S, ?>> {
	private Map<Object, Object> map = new HashMap<>();

	public <K extends H<S,T>, T> void put(K key, T value) {
		map.put(key, value);
	}

	@SuppressWarnings("unchecked")
	public <K extends H<S,T>, T> T get(K key) {
		return (T) map.get(key);
	}
}

With this we’ve almost managed to get what we wanted. We can operate with the map as follows:

HeterogeneousMap<UserOption<?>> map = new HeterogeneousMap<>();
map.put(UserOption.TIMEZONE, "America/Argentina/Buenos_Aires");
map.put(UserOption.THEME, WebsiteTheme.DARK);
String timezone = map.get(UserOption.TIMEZONE);
WebsiteTheme theme = map.get(UserOption.THEME);
/* etc. */

as can be seen, we can put stuff into the map using the UserOption constants, and don’t need any casting when getting values out. Additionally, none of the following compile:

/* Putting a value of the wrong type for the given key */
map.put(UserOption.TIMEZONE, 3);

/* Getting a value of the wrong type from the map */
String timezone = map.get(UserOption.THEME);

/* Wrong key type. Though SOME_PARAM represents a string parameter, we declared
 * the map for UserOption, not for OptionalParameter */
map.put(OptionalParameter.SOME_PARAM, "extraString");

/* As before, the map was declared for UserOption, so we cannot use
 * OptionalParameter as key */
String extraParam = map.get(OptionalParameter.SOME_PARAM)

The limitation with this is that we have no way of enforcing that a type implements H the right way. As an example, we could have:

public class OptionalParameter<T> implements H<UserOption<?>, T> {
	/* the rest is the same as before */
}
HeterogeneousMap<UserOption<?>> map = new HeterogeneousMap<>();
map.put(OptionalParameter.INTEGER_PARAM, 3);
map.get(OptionalParameter.INTEGER_PARAM);

In other words, we’ve managed to use keys of type OptionalParameter in a map meant for UserOption keys. It is possible to avoid this by using an annotation processor to prohibit (having a compilation error) a type T implementing H with an argument other than itself. This is what projects such as derive4j/hkt do, but I won’t go now further in that direction.

The technique is still useful as is, for instance when one controls the code and knows to abide by the restriction that whenever a new type K<T> of key wants to be used, that type needs to implement H<K<?>,T>.


  1. A phantom type is a type with a type parameter which isn’t used (in methods nor fields). The definition might change slightly based on the language where it’s used, but the gist is the same. Their use is known in languages such as Haskell and Rust, though Java supports them too. ↩︎

  2. https://en.wikipedia.org/wiki/Kind_(type_theory) ↩︎