“Symmetry is what we see at a glance; based on the fact that there is no reason for any difference…”
Blaise Pascal
When you write ${date + 3} in a template, TemplateLocalDate knows how to add an integer. When you write ${3 + date}, the roles are swapped: the number is on the left, and TemplateNumber has never heard of dates. Historically FreshMarker only dispatched operators on the left operand, which meant that a new type could never opt into being the right operand of an operator without patching every other type. This post walks through the design and implementation of the new symmetric operator dispatch that fixes exactly that.
Everything below refers to FreshMarker 3.0.0. The mechanism is fully additive: no existing template changes behaviour, and no existing extension needs to be modified.
The problem: one-sided dispatch
Every binary expression in FreshMarker (+, -, , /, %, ~, <, >=, ==, …) is executed by asking the left operand what to do:
public interface TemplateObject {
default TemplateObject operation(Operator op, TemplateObject right, ProcessContext ctx) {
throw new ProcessException("unsupported operation: " + op);
}
default boolean equality(TemplateObject other, ProcessContext ctx) {
throw new ProcessException("unsupported equality: " + TokenType.EQUALS);
}
// relation(...) analogous
}
TemplateNumber overrides operation(…) and knows how to add two numbers. TemplateString overrides it and knows how to concatenate two strings. TemplateLocalDate knows how to add an integer number of days.
That approach works beautifully as long as the left operand is a type that owns the operation. It breaks down the moment you want to write:
${3 + someDate} <#-- Integer on the left, LocalDate on the right -->
${5 + "items"} <#-- Integer + String → concatenation -->
${myCustomMoney + 42} <#-- User-defined type on either side -->
TemplateNumber.operation(PLUS, TemplateLocalDate, …) fails, because TemplateNumber has no business knowing about dates. Adding date support to TemplateNumber would mean coupling the number class to every possible right-operand type. The opposite of what an extension system should look like.
The old string special case
Because String + apnything and anything + String is so common in templates, FreshMarker used to hard-code a workaround in TemplatePlusOperation:
protected TemplateObject getTemplateObject(ProcessContext ctx,
TemplateObject left,
TemplateObject right) {
if (left.getModelType().equals(right.getModelType())) {
return left.operation(Operator.PLUS, right, ctx);
}
if (left.isPrimitive() && right.isPrimitive()) {
if (left instanceof TemplateString s) {
return s.operation(Operator.PLUS, right.asString(), ctx);
}
if (right instanceof TemplateString s) {
return left.asString().operation(Operator.PLUS, s, ctx);
}
}
return left.operation(Operator.PLUS, right, ctx);
}
Line 12 is the whole story: if either side is a TemplateString, both sides get coerced to strings and the operation is dispatched on the (guaranteed) string left. Neat, but:
- It is hard-wired to
TemplateString. No other type can opt in. - It duplicates knowledge across
TemplatePlusOperation,, and the two compiler stages (TemplateConcatOperationJavaSourceCodeGenerator,TypedJavaSourceCodeGenerator). - Extending it to a new type (say,
TemplateMoneythat wantsNumber + Money) requires editing code that has no natural business knowing about that type.
The design: two-stage dispatch with reverse hooks
The design goal was to make participation as the right operand a first-class concern of the type that wants it, without breaking any existing dispatch.
The mechanism is a two-stage lookup:
- Forward: ask the left operand
left.operation(op, right, ctx). This is the historical path and remains the fast, common case. - Reverse fallback: if the forward call throws, ask the right operand via a new hook
right.operationReverse(op, left, ctx). Returningnullmeans “I do not know how to handle this either“; the dispatcher then rethrows the original exception.
The same pattern applies to relational comparisons and equality. Concretely, three new default methods appear on TemplateObject (and one on TemplatePrimitive):
public interface TemplateObject {
default TemplateObject operationReverse(Operator op, TemplateObject left, ProcessContext ctx) {
return null;
}
default Boolean equalityReverse(TemplateObject left, ProcessContext ctx) {
return null;
}
}
public class TemplatePrimitive<P> implements TemplateObject {
public TemplatePrimitive<?> relationalReverse(Relation op, TemplatePrimitive<?> left,
ProcessContext ctx) {
return null;
}
}
A null return preserves the historical exception; a non-null return replaces it. The default implementations are no-ops, so no existing type changes behaviour and no extension code needs to be touched.
One dispatcher, three flavours
All existing call sites TemplatePlusOperation, TemplateDefaultOperation, TemplateConcatOperation, TemplateEquality, TemplateRelational now route through a single new utility:
public final class OperatorDispatcher {
public static TemplateObject operation(Operator op, TemplateObject left,
TemplateObject right, ProcessContext ctx) {
try {
return left.operation(op, right, ctx);
} catch (ProcessException failure) {
TemplateObject reverse = right.operationReverse(op, left, ctx);
if (reverse != null) {
return reverse;
}
throw failure;
}
}
public static TemplatePrimitive<?> relational(Relation op, TemplatePrimitive<?> left,
TemplatePrimitive<?> right, ProcessContext ctx) {
/* analogous */
}
public static boolean equality(TemplateObject left, TemplateObject right, ProcessContext ctx) {
try {
boolean result = left.equality(right, ctx);
if (result) {
return true;
}
// Left said "no". Right may know something the left doesn't.
Boolean reverse = right.equalityReverse(left, ctx);
return reverse != null ? reverse : false;
} catch (ProcessException failure) {
Boolean reverse = right.equalityReverse(left, ctx);
if (reverse != null) {
return reverse;
}
throw failure;
}
}
}
Two design decisions worth calling out:
- operation and relational catch on exception. An
UnsupportedOperationException-styleProcessExceptionis the natural “I don’t know” signal from an existing type, so the dispatcher piggybacks on it rather than requiring every type to be rewritten to returnnull. - equality also consults the reverse hook on a false result. This is deliberate: two objects of different Java types will typically return
fromfalse, but that answer is not authoritative. Aequalsthat wants to compare equal toLenientNumberneeds a chance to override it.TemplateString("7")
Killing the string special case
With the dispatcher in place, the hard-coded special case in TemplatePlusOperation shrinks to:
@Override
protected TemplateObject getTemplateObject(ProcessContext ctx,
TemplateObject left,
TemplateObject right) {
return OperatorDispatcher.operation(Operator.PLUS, left, right, ctx);
}
That’s the entire operator. All the string-specific logic moves into TemplateString itself, where it belongs, expressed as an override of the new reverse hook:
public class TemplateString extends TemplatePrimitive<String> {
@Override
public TemplateObject operationReverse(Operator op, TemplateObject left, ProcessContext ctx) {
if (op != Operator.PLUS && op != Operator.CONCAT) {
return null;
}
if (!left.isPrimitive()) {
return null;
}
try {
return left.asString().operation(op, this, ctx);
} catch (ProcessException e) {
return null;
}
}
}
TemplateString is now the only place in the code base that knows about the string-coercion behaviour of X + string. If a future refactor changes how strings behave in cross-type addition, there is exactly one file to touch.
The pay-off: n + date now works
Adding Integer + LocalDate support becomes a two-line change — entirely local to the date type:
public class TemplateLocalDate extends TemplatePrimitive<LocalDate> implements TemplateDate {
@Override
public TemplateObject operation(Operator op, TemplateObject right, ProcessContext ctx) {
// ... unchanged: date + number, date + period, date - number, date - period
}
/**
* Enables `n + date` (integer on the left, date on the right). Addition is
* commutative, so we simply delegate back to the forward operation with the
* operands swapped. Subtraction is not supported in reverse, `n - date` has
* no natural meaning.
*/
@Override
public TemplateObject operationReverse(Operator op, TemplateObject left, ProcessContext ctx) {
if (op == Operator.PLUS) {
return operation(Operator.PLUS, left, ctx);
}
return null;
}
}
TemplateNumber did not have to change. TemplateOperation did not have to change. No registry had to be updated. The date class opted in, and the dispatcher took care of the rest.
The regression test exercises both orderings:
@ParameterizedTest
@CsvSource({
"0,1968-08-24",
"1,1968-08-25",
"3,1968-08-27",
"7,1968-08-31",
"10,1968-09-03",
"365,1969-08-24"
})
void dateOperationIntegerReversed(int n, String expected, TemplateBuilder templateBuilder) {
Template dateFirst = templateBuilder.getTemplate("dateFirst", "${temporal + n}");
Template intFirst = templateBuilder.getTemplate("intFirst", "${n + temporal}");
Map<String, Object> model = Map.of(
"temporal", LocalDate.of(1968, Month.AUGUST, 24),
"n", n);
assertEquals(expected, dateFirst.process(model));
assertEquals(expected, intFirst.process(model)); // <-- the new capability
}
Before FreshMarker 3.0.0 the second assertEquals failed Now both orderings produce 1968-08-24 + n days.
Semantics you can rely on
A few properties fall out of the design that are worth being explicit about:
- Backwards compatible. Every existing type’s forward-only dispatch is invoked first, and any type that has never heard of the reverse hook simply returns
null. Existing templates and existing extensions behave exactly as before. - Deterministic. The forward call always wins if it succeeds. The reverse hook is a fallback, not an override — a type cannot silently intercept an operation that the left operand already knows how to perform.
- Locality. All knowledge of “how does
Xbehave as the right operand of some operator” lives onX. No central table has to be curated, no order of registration matters. - Error messages are preserved. If neither side knows what to do, the exception raised by the left operand’s forward call is rethrown unchanged, so error output continues to point at the most informative party.
What about the compiler stages?
The typed compiler (TypedJavaSourceCodeGenerator) still contains two hard-coded string fallbacks. Those are safe today because the typed compiler always falls back to the interpreter for any operator combination it cannot inline, and the interpreter now dispatches correctly. There is no functional regression — just a missed optimisation for user-defined reverse hooks. Generalising the compiled path is a separate, code-generator-focused piece of work; it would take the shape of a SPI analogous to the existing CompiledOperationProvider, emitting inline Java code fragments for known type pairs. That is a story for another post.CompiledBuiltInProvider
Try it
To exercise the new mechanism in your own code, all you need is a TemplateObject implementation with a reverse hook:
class Tag extends TemplatePrimitive<String> {
Tag(String value) {
super(value);
}
@Override
public TemplateObject operationReverse(Operator op, TemplateObject left, ProcessContext c) {
if (op != Operator.PLUS) {
return null;
}
return new TemplateString("[" + left + "|" + getValue() + "]");
}
@Override
public String toString() {
return getValue();
}
}
Now produces ${42 + myTag} from a class that no existing FreshMarker type has ever heard of.[42|myTag]
Summary
- The historical left-only operator dispatch has been generalised with a reverse hook pattern:
operationReverse,equalityReverse,relationalReverse. - A single new
OperatorDispatcherutility encapsulates the two-stage lookup and is the sole call site used byTemplatePlusOperation,TemplateDefaultOperation,TemplateConcatOperation,TemplateEquality, andTemplateRelational. - The old hard-coded
TemplateStringspecial case has moved intoTemplateString.operationReverse, where it belongs. - Third-party types can now participate as the right operand of any operator without any other type or the framework itself having to know about them.
- All 3373 existing tests pass; six new tests cover the mechanism end-to-end, and six more prove that
n + dateanddate + nproduce identical results across a range ofn.
Symmetry, at last.