In the FreshMarker template engine, hashes are a central data structure. Hashes access data via a name. Up until version 2.6.0, FreshMarker only supported hashes that used strings as names. Hashes that allow enums as names now exist, but their integration is far from complete.
In the List directive, FreshMarker supports hashes in addition to sequences. However, until now, these could only be of types that allow string keys. These include all beans and records, as well as map implementations with string keys.
<#list config as key sorted asc, value>
${key} = ${value}
</#list>
This has been working reliably for Map<String, Object> for a long time. But what about Map<Enum<?>, Object>, such as a configuration with enum keys?
enum Priority { LOW, MEDIUM, HIGH }
Map<Priority, String> labels = Map.of(
Priority.LOW, "Niedrig",
Priority.MEDIUM, "Mittel",
Priority.HIGH, "Hoch"
);
template.process(Map.of("config", labels));
Original state: Enum hashes were not working
The List directive internally used List<Entry<String, Object>> for hashes. The HashListFragment obtained its entries via a map() method in TemplateBean, which returned the internal model of type Map<String, Object>. After TemplateBean was split into two separate classes, TemplateStringBean and TemplateEnumBean, to support Enum hashes, the problem arose that TemplateEnumBean did not have a meaningful implementation for map().
One option would have been to convert the Map<Enum<?>, Object> to a Map<String, Object>, but then the keys in the template would be of type String and not Enum. This alternative was not preferred and was therefore discarded. As a consequence, there was initially, no support for the List directive with Enum hashes.
A first step: Map<Object, Object>—functional but messy
The obvious problem with the List directive and the hash implementations is that they are limited to a Map<String, Object>. Therefore, the solution of using a Map<Object, Object> instead immediately comes to mind. In this case, both hash implementations can pass in their internal map, or the common base class can handle this task.
@SuppressWarnings("unchecked")
public Set<Map.Entry<Object, Object>> rawEntries() {
return (Set<Map.Entry<Object, Object>>) (Set<?>) map.entrySet();
}
The method is efficient but very ugly, as it circumvents Java’s type system. On the other hand, this approach has allowed us to achieve an important intermediate step: Enum hashes can be used in the modified List directive without any restrictions. However, we need a better approach.
The actual solution: TemplateHashEntry with a sealed HashKey
The first solution changed the key type in the Entry class. Why not just replace the Entry class? After all, that’s what’s actually causing us problems. Instead of using a generic wildcard type, the new record TemplateHashEntry models each hash entry with a sealed interface for the key:
public record TemplateHashEntry(HashKey key, Object value) {
public static TemplateHashEntry of(String key, Object value) {
return new TemplateHashEntry(new StringHashKey(key), value);
}
public static TemplateHashEntry of(Enum<?> key, Object value) {
return new TemplateHashEntry(new EnumHashKey(key), value);
}
public Object keyObject() {
return key.value();
}
public sealed interface HashKey permits StringHashKey, EnumHashKey {
Object value();
}
public record StringHashKey(String value) implements HashKey {
}
public record EnumHashKey(Enum<?> value) implements HashKey {
}
}
The sealed interface HashKey has exactly two implementations: StringHashKey and EnumHashKey. The Java compiler ensures that switch expressions using these are complete. The HashListFragment now populates its list using clean pattern matching and without any casting:
private List<TemplateHashEntry> getList(ProcessContext context) {
AbstractTemplateBean<?> abstractTemplateBean = list.evaluate(context, AbstractTemplateBean.class);
if (comparator != null) {
return filterSequence(context, getSortedTemplateHashEntries(abstractTemplateBean, comparator));
}
return filterSequence(context, getTemplateHashEntries(abstractTemplateBean));
}
private static List<TemplateHashEntry> getTemplateHashEntries(AbstractTemplateBean<?> abstractTemplateBean) {
return switch (abstractTemplateBean) {
case TemplateStringBean stringBean -> stringBean.entries().stream().map(entry -> TemplateHashEntry.of(entry.getKey(), entry.getValue())).toList();
case TemplateEnumBean enumBean -> enumBean.entries().stream().map(entry -> TemplateHashEntry.of(entry.getKey(), entry.getValue())).toList();
};
}
private static List<TemplateHashEntry> getSortedTemplateHashEntries(AbstractTemplateBean<?> abstractTemplateBean, Comparator<Object> comparator) {
final Comparator<TemplateHashEntry> entryComparator = (left, right) -> comparator.compare(left.keyObject(), right.keyObject());
return switch (abstractTemplateBean) {
case TemplateStringBean stringBean -> stringBean.entries().stream().map(entry -> TemplateHashEntry.of(entry.getKey(), entry.getValue())).sorted(entryComparator).toList();
case TemplateEnumBean enumBean -> enumBean.entries().stream().map(entry -> TemplateHashEntry.of(entry.getKey(), entry.getValue())).sorted(entryComparator).toList();
};
}
Enum hashes can now be fully iterated over:
<#list priorities as key sorted asc, label>
${key}: ${label}
</#list>
With Map<Priority, String>, this returns:
LOW: Niedrig MEDIUM: Mittel HIGH: Hoch
Thw sorted asc and sorted desc also work on enum keys, since the natural comparator is applied via Enum.compareTo (ordinal order).
Fazit
The journey from an incomplete implementation, through a map workaround, to a type-safe solution using records and sealed interfaces demonstrates just how much modern Java features help reduce technical debt through clear, understandable design steps. TemplateHashEntry isn’t just cosmetic refactoring—it’s what makes Enum hashes fully usable in FreshMarker.