The ~~ Operator: Formatted Concatenation in FreshMarker

FreshMarker has introduced the ~ operator, which concatenates values with a space. With FreshMarker 3.0.0, ~~ is introduced: the same concept, but this time non-textual primitives are passed through the configured formatter before concatenation—numbers, dates, and other primitives thus appear exactly as they do in the rest of the template.

The Problem with Simple Concatenation

FreshMarker has had the ~ operator for quite some time. It concatenates two values with a space as a separator and skips the separator, if one of the values is an empty string. This is useful for things like first and last names:

${'Max' ~ 'Mustermann'}        <#-- "Max Mustermann" -->

But here’s what ~ doesn’t do: it formats. If a number or date appears on a page, it simply calls the Java class’s toString() method. The result completely ignores whatever number or date format you’ve specified in the configuration:

<#-- Configured: numberFormat="#,##0.00", dateFormat="dd.MM.yyyy" -->
${4.0 ~ someDate}   <#-- "4.0 2026-08-01"  <- Java toString, not a formatter! -->

For templates that combine mixed types into a single expression, this is rarely what you’d expect.

The formatted concat operator

The new ~~ operator fills exactly this gap. It behaves exactly like ~ on the surface by using a space as a separator, and skipping the separator if an operand is empty. But it formats every primitive value using the Formatter registered in Configuration before concatenating the strings:

<#-- Configured: numberFormat="#.000", dateFormat="medium" -->
${4.0 ~~ someDate}   <#-- "4,000 01.08.2026" -->

Strings are already strings and are passed through unchanged; for all other primitives like NumberLocalDateLocalDateTime, etc. the formatter takes effect.

Space Semantics

Like ~, ~~ skips the separator if an operand is an empty string. This is particularly useful when template variables are optionally filled:

${prefix ~~ 'Mitte' ~~ suffix}
<#-- prefix="" and suffix="" → "Mitte"  (no leading/trailing space) -->
<#-- prefix="A" and suffix="Z" → "A Mitte Z" -->

Multiple Operands in an Expression

Like all FreshMarker operators, ~~ is left-associative and can be chained as desired:

${'Very' ~~ 'Dear' ~~ salutation ~~ last_name?upper_case ~~ ','?}
<#-- "Dear Ms. MUELLER,"  -->

Interaction with Built-ins

The result of ~~ is always a string, so string built-ins can be appended directly:

${('abcdefg' ~~ 'hijklmnop' ~~ 'qrstuvwxyz')?upper_case}
<#-- "ABCDEFG HIJKLMNOP QRSTUVWXYZ" -->

Comparison of +~, and ~~

OperatorSeparatorFormatted?Typical Use Case
+nonenoConcatenate strings exactly, add numbers
~spacenoJoin words/tokens when no formatter is needed
~~spaceyes (via configured formatter)Combine numbers, dates, and strings into a neatly formatted sentence
<#-- number = 4.0, configured numberFormat="#.000" -->
${'Value:' + number}   <#-- "Value:4.0"     (toString) -->
${'Value:' ~ number}   <#-- "Value: 4.0"    (toString, space) -->
${'Value:' ~~ number}  <#-- "Value: 4,000"  (formatter, space) -->

Technical Background

Why ~~ Is Not Folded at Compile Time

FreshMarker can evaluate constant expressions during parsing (constant folding). 'a' ~ 'b' is immediately expanded to 'a b', the operation never reaches runtime.

The operator ~~, on the other hand, is deliberately not folded, even if both operands are literals. The result depends on the Locale valid at runtime and the registered Formatter and may differ from one template call to the next. The expression therefore remains in the AST as a TemplateConcatOperation.

Compiler Support

Both the Phase 1 compiler (untyped, without a schema) and the Phase 2 compiler (typed, with a schema) support the ~~ operator. Because formatting is locale-dependent and unknown at AOT compile time, both compiler stages delegate ~~ expressions back to the interpreter at runtime. This is the standard fallback pattern for anything that cannot be safely generated inline.

For type inference, ~~ always returns String.class, just like ~.

Conclusion

The operator ~~ is a small extension with a big impact: It bridges the gap between the practical space-based concatenation and configured formatting, and makes templates more readable because you no longer have to explicitly insert ?string or ?format just to get the correct format.

Leave a Comment