The previous post looked at the Stage 1 (untyped) compiler and finished with an honest observation: Stage 1 removes fragment-dispatch overhead but still evaluates every expression through TemplateObject. This third and final post explains how the Stage 2 compiler uses a TemplateTypeSchema to emit raw Java that talks directly to your data model — and how that unlocks the ~2.5× AOT speedup from the benchmark chart in the first post.
Everything below refers to FreshMarker 3.0.0. The compiler is experimental; the code shape of the generated class is not part of the public API.
The idea in one sentence
Stage 2 replaces every expr_N.evaluate(ctx, TemplateXxx.class).getValue() in the generated method with a plain Java expression like (String) dataModel.get("name".toUpperCase(ctx.getLocale()). To do that, the compiler needs to know the Java type of every path in your data model. Which is exactly what TemplateTypeSchema provides.
TemplateTypeSchema in five minutes
A TemplateTypeSchema maps dotted paths to Java classes:
TemplateTypeSchema schema = TemplateTypeSchema.builder()
.field("name", SchemaType.STRING)
.field("age", SchemaType.INTEGER)
.field("active", SchemaType.BOOLEAN)
.object("address") // nested Map<String, Object>
.field("city", SchemaType.STRING)
.field("zip", SchemaType.STRING)
.end()
.list("items") // list with typed elements
.field("name", SchemaType.STRING)
.field("price", SchemaType.DOUBLE)
.end()
.build();
The wildcard .list(…) step causes the compiler to store the child schema under items.* internally, so items[0].price and items[999].price resolve to the same Double.class.
SchemaType is not an enum but a container of Class<?> constants. That means the schema is extensible: any Java class works, including your own domain types. The predefined constants cover STRING, BOOLEAN, INTEGER, LONG, DOUBLE, FLOAT, BYTE, SHORT, CHARACTER, BIG_DECIMAL, BIG_INTEGER, LOCAL_DATE, LOCAL_TIME, LOCAL_DATE_TIME, ZONED_DATE_TIME, INSTANT, DURATION, PERIOD, UUID, OBJECT and LIST. Concrete List/Map implementations (e.g. ArrayList, LinkedHashMap) are recognised automatically.
Beans and records
If a value is a real Java bean or record, tell the schema so it can emit typed field accesses:
TemplateTypeSchema schema = TemplateTypeSchema.builder()
.beanList("items", "com.example.Stock") // bean-style accessors (getX / isX)
.field("symbol", SchemaType.STRING)
.field("price", SchemaType.DOUBLE)
.end()
.build();
// or, for a Java record:
TemplateTypeSchema recordSchema = TemplateTypeSchema.builder()
.recordList("items", "com.example.StockRecord")
.field("symbol", SchemaType.STRING)
.field("price", SchemaType.DOUBLE)
.end()
.build();
Internally these become BeanMapping(className, AccessorStyle.BEAN|RECORD) entries. The generator uses them to pick between ((com.example.Stock) item_0).getSymbol() and ((com.example.StockRecord) item_0).symbol().
The schema file format
The CLI reads the exact same information from a plain text file, one line per path:
# src/test/resources/quarterly-report.schema reportDate localdate department string analyst string quarter integer products list products.* record:org.freshmarker.core.compiler.QuarterlyReportTemplateTest$Product products.*.name string products.*.revenue double products.*.status string products.*.notes string safeValue string multiplier integer
TemplateTypeSchema.parse(List<String>) turns those lines into the same schema you would have built with the fluent API.
The compile pipeline
TypedJavaSourceCodeGenerator is the largest class in the compiler package. It extends AbstractJavaSourceCodeGenerator and layers three main pieces on top.
1. TypeInferenceVisitor
Before generating any code, an inner TypeInferenceVisitor walks the whole fragment tree and fills an IdentityHashMap<TemplateObject, Class<?>> (the typeCache). It propagates types through:
- variables and dot access via the schema and the current scope stack,
- operators using the same numeric-widening rules as the interpreter,
- every built-in registered as a
CompiledBuiltIn, via thereturnTyperecorded inCompiledBuiltInRegister.BuiltInRecord, TemplateMarkupandTemplateBuiltInVariable.
The identity map ensures every expression node has a single cached type by the time code generation starts, so the resolver never re-computes anything.
2. RawExpressionResolver
RawExpressionResolver converts a TemplateObject subtree into a Java expression string. The rules are straightforward once the types are known:
TemplateVariable("name")(top-level) →(String) dataModel.get("name")with the cast picked from the schema.TemplateDotKey(parent, "price")→ resolveparentrecursively; if the parent has a bean or record mapping, emit((com.example.Stock)parent).getPrice()for a bean orfor a record. Otherwise emit((com.example.Stock)parent).price()(Double) ((Map<String, Object>) parent).get("price").TemplateOperation(left, +, right)→MathHelper.emitAddition(leftCode, leftType, rightCode, rightType)which handles numeric widening (BigDecimal>BigInteger>Double>Float>Long>Integer>Short>Byte).TemplateBuiltIn(expr, "upper_case")→ look up the compiled built-in for the resolved type ofexprin theCompiledBuiltInRepository, and hand it the raw expression string to wrap.
Whenever the resolver hits something it does not know how to lower (a lambda, a macro call, an unsupported built-in, a mixed-type arithmetic operation), it just calls captureExpression(…) from the base class and produces the interpreter fallback: expr_N.evaluate(ctx, TemplateXxx.class).getValue(). Nothing breaks but you just miss one optimisation point.
3. PathResolver and the scope stacks
Loops introduce local Java variables (item_0, product_1, …) and simultaneously push a schema path onto a Deque<Map<String, String>> pathStack. When the body of <#list products as p> references ${p.revenue}, the resolver:
- Looks up
inpscopeStackand finds it maps to the Java variableproduct_0. - Looks up
pinpathStackand finds it maps to the schema pathproducts.*. - Reads
schema.get("products.*.revenue")wich results inDouble.class. - Consults
schema.getBeanMapping("products.*"). Which, if present, emits((com.example.Product) product_0).getRevenue();otherwise the plainMapaccess.
Nested loops just push another frame onto both stacks.
What the generated code looks like
The following snippets follow the current 3.0.0 shape emitted by TypedJavaSourceCodeGenerator.
${name} with name: STRING in the schema
CompiledMarkup.markup(w, dataModel.get("name"), ctx);
CompiledMarkup.markup(Writer, Object, ProcessContext) is the runtime bridge that looks up the correct formatter and honours the current output-format escaper — so ${amount} still respects a <#setting number_format='…'>.
${name?upper_case}
w.write(CompiledStringBuiltIns.upperCase((String) dataModel.get("name"), ctx.getLocale()));
<#if active> with active: BOOLEAN
if ((Boolean) dataModel.get("active")) {
// ...
}
<#list items as item> with a plain typed list
for (Object item_0 : (List<?>) dataModel.get("items")) {
// body: item_0 is used where the interpreter would have used the environment variable "item"
}
<#list items as item> with element item mapped to a bean com.example.Stock
for (Object item_0 : (List<?>) dataModel.get("items")) {
// ${item.symbol}
w.write(((com.example.Stock) item_0).getSymbol());
}
<#list items as item> — element declared as SchemaType.OBJECT
for (Object item_0 : (List<?>) dataModel.get("items")) {
Map<String, Object> item_0_map = (Map<String, Object>) item_0;
// ...
}
Compare this with Stage 1’s captured expr_N.evaluate(ctx, TemplateXxx.class).getValue() calls: there is no TemplateObject in the hot path anymore. The JIT sees ordinary field access, an ordinary cast, and an ordinary method call. The exact patterns HotSpot loves to inline.
The compiled built-in SPI
Built-ins are lowered through a registry rather than a switch. The functional interface is:
@FunctionalInterface
public interface CompiledBuiltIn {
String generateCode(String rawExpression, String contextVar, List<String> parameters);
}
Implementations return a Java expression string to embed in the generated code. Each entry is stored in a CompiledBuiltInRegister as BuiltInRecord(Class<?> returnType, CompiledBuiltIn value) — the returnType is what TypeInferenceVisitor uses to propagate the resulting type upwards.
CompiledBuiltInRepository walks the type hierarchy (superclass, then interfaces) when looking up type and name, so a built-in registered against Number.class matches for Integer, Long, BigDecimal, and so on.
The standard built-ins live in a handful of provider classes registered via .META-INF/services/org.freshmarker.api.extension.Extension
Adding your own is a two-liner via :CompiledBuiltInProvider
configuration.register((CompiledBuiltInProvider) () -> {
CompiledBuiltInRegister register = new CompiledBuiltInRegister();
register.add(SchemaType.STRING, "my_builtin", (raw, ctx, params) ->
"com.example.MyUtils.myBuiltin((String) " + raw + ")");
return register;
});
CompiledMarkup: keeping formatters and output-format alive
Removing TemplateObject from the hot path threatens two runtime concerns:
- Formatters installed via
Configuration#registerFormatteror<#setting>. - Output-format escaping stacked via
<#outputformat>.
CompiledMarkup.markup(Writer w, Object value, ProcessContext ctx) is the small runtime helper that takes any raw Java value, looks up the correct formatter for its class, applies the top-of-stack output-format escaper, and writes the result. It is called for every typed interpolation, which is why compiled output remains byte-for-byte identical to interpreted output.
Fallback semantics
Whenever RawExpressionResolver cannot lower a subtree (e.g. a lambda passed to , a ?filter<@lib.macro/> call, …) it falls back to a captured TemplateObject or Fragment. The generated code still runs; the affected expression is just evaluated by the interpreter.
Practically that means:
- You can add a schema to a template that uses macros; only the macros pay the interpreter cost.
- You can extend the schema incrementally — first the top-level fields, then the important lists, then the bean mappings — and each addition unlocks more optimisation.
With Stage 1 removing dispatch overhead and Stage 2 removing boxing, FreshMarker 3.0.0 gives you a smooth path from “turn compilation on and see what happens” to “a fully AOT-compiled, schema-driven template class checked in as normal Java source”. Without ever leaving the familiar Configuration/Template/ProcessContext API.