JJTemplate is a lightweight templating engine designed for minimal render time and JSON-compatible input/output. JJT stands for Java JSON Template. JJTemplate compiles templates into optimized abstract syntax trees (ASTs) for fast execution while guaranteeing valid JSON results.
IDEA plugin: here.
<dependency>
<groupId>io.github.sibmaks.jjtemplate</groupId>
<artifactId>jjtemplate</artifactId>
<version>{version}</version>
<type>pom</type>
</dependency>implementation("io.github.sibmaks.jjtemplate:jjtemplate:{version}")The following class can be copied and run as-is:
import io.github.sibmaks.jjtemplate.compiler.api.TemplateCompiler;
import io.github.sibmaks.jjtemplate.compiler.api.TemplateScript;
import java.util.Map;
public class Main {
public static void main(String[] args) {
var script = TemplateScript.builder()
.template(Map.of(
"message", "{{ string:concat 'Hello, ', .name }}"
))
.build();
var compiler = TemplateCompiler.getInstance();
var compiled = compiler.compile(script);
var result = compiled.render(Map.of("name", "Alice"));
System.out.println(result); // {message=Hello, Alice}
}
}Implement TemplateFunction and define the namespace and name used in JJT expressions. For example, the following
function is available as custom:reverse:
import io.github.sibmaks.jjtemplate.compiler.runtime.fun.TemplateFunction;
import java.util.List;
public final class ReverseTemplateFunction implements TemplateFunction<String> {
@Override
public String invoke(List<Object> args, Object pipeArg) {
if (!args.isEmpty()) {
throw fail("no arguments expected after the pipe value");
}
return reverse(pipeArg);
}
@Override
public String invoke(List<Object> args) {
if (args.size() != 1) {
throw fail("exactly 1 argument required");
}
return reverse(args.get(0));
}
@Override
public String getNamespace() {
return "custom";
}
@Override
public String getName() {
return "reverse";
}
@Override
public boolean isDynamic() {
return false;
}
private String reverse(Object value) {
return value == null ? null : new StringBuilder(value.toString()).reverse().toString();
}
}Register the function in the evaluation options and create the compiler with those options:
var evaluationOptions = TemplateEvaluationOptions.builder()
.functions(List.of(new ReverseTemplateFunction()))
.build();
var compileOptions = TemplateCompileOptions.builder()
.evaluationOptions(evaluationOptions)
.build();
var compiler = TemplateCompiler.getInstance(compileOptions);The function can then be called directly or through a pipe:
{
"direct": "{{ custom:reverse .value }}",
"pipe": "{{ .value | custom:reverse }}"
}Templates are written in pure JSON with embedded expressions using double curly braces:
{
"definitions": [
{
"greeting": "{{ string:concat 'Hello, ', .name }}"
}
],
"template": {
"message": "{{ .greeting }}"
}
}-
.varName— access variable values -
{{ expression }}— direct expression substitution -
{{? expression }}— conditional insertion (skips if null) -
{{. expression }}— spread values into arrays or objects
Supports expressions, pipe calls (|), and ternary operators (?, :), function argument spread (...).
.varName- Access variable values from context- Supports nested object access (e.g.,
.user.profile.name)
Variable definitions: static, conditional (switch), and range-based (range). A range exposes item,index for
collections and arrays, and key,value for maps.
Functions are organized into namespaces by type or purpose.
Call syntax uses a colon (:), e.g. {{ cast:str .value }} or {{ .text | string:upper }}.
cast:str(value)— Convert to stringcast:int(value)— Convert to integer (BigInteger)cast:float(value)— Convert to decimal (BigDecimal)cast:boolean(value)— Convert to boolean
string:concat(base, ...values)— Concatenate stringsstring:join(glue, ...values)— Concatenate strings with glue between valuesstring:joinNotEmpty(glue, ...values)— Concatenate strings with glue between values, skip null and empty valuesstring:len(string)— Get string lengthstring:empty(string)— Check if empty or nullstring:contains(string, ...substrings)— Check if all substrings exist in stringstring:format([locale], pattern, ...args)— Format string (likeString.format)string:lower([locale], value)— Convert to lowercasestring:upper([locale], value)— Convert to uppercasestring:trim(value)— Remove all leading and trailing spacestring:split(value, regex, [limit])— Splits this string around matches of the given regular expression.string:indexOf(value, str)— Returns the index within this string of the first occurrence of the specified substring.string:lastIndexOf(value, str)— Returns the index within this string of the last occurrence of the specified substring.string:substr(value, beginIndex, [endIndex])— Returns a string that is a substring of this string. Support negative indexes.string:replace(value, target, replacement)— Replaces each substring of this string that matches the literal target sequence with the specified literal replacement sequence.string:replaceAll(value, regex, replacement)— Replaces each substring of this string that matches the given regular expression with the given replacement. The substring begins at the specifiedbeginIndexand extends to the character at indexendIndex - 1.
list:new(...items)— Create a listlist:concat(...lists)— Concatenate multiple lists or arrayslist:len(list)— Get sizelist:empty(list)— Check if emptylist:contains(list, ...values)— Check if list contains all valueslist:head(list)— Get head of list or nulllist:tail(list)— Get tail of list or empty listlist:join(glue, ...lists)— Join all lists into single string
map:new(key, value, ...)— Create a mapmap:len(map)— Get number of entriesmap:empty(map)— Check if emptymap:contains(map, ...keys)— Check if all keys existmap:collapse(object|array|collection)— Merge object properties into one map
date:format([locale], pattern, date)— Format date (Date,GregorianCalendar,LocalDate,LocalDateTime,ZonedLocalDateTime)date:parse(pattern, string)— Parse string intoLocalDatedate:now()— Get currentLocalDate
datetime:parse(pattern, string)— Parse string intoLocalDateTimedatetime:now()— Get currentLocalDateTime
locale:new(language[, country[, variant]])— Create aLocaleinstance
numberFormat:new(locale[, settings])— Create aNumberFormatinstance for the specifiedLocaleand optional settingsMap. Supportedsettingskeys:
style(number|integer|currency|percent),groupingUsed,parseIntegerOnly,maximumIntegerDigits,minimumIntegerDigits,maximumFractionDigits,minimumFractionDigits,currency,roundingMode.
math:neg(value)— Negate numeric valuemath:sum(left, right)— Sum two numeric valuesmath:sub(left, right)— Subtract two numeric valuesmath:mul(left, right)— Multiply two numeric valuesmath:div(left, right, [mode])— Divide two numeric values and scale using passedmode.math:scale(value, amount, mode)— Returns afloatwhose scale is the specified value, and whose unscaled value is determined by multiplying or dividing thisfloat's unscaled value by the appropriate power of ten to maintain its overall value.
default(value, fallback)— Return fallback if value isnull
Use ?. when a property may not exist on the runtime object. A missing property
is resolved as null, so it can be combined with default:
{
"on": "{{ default .repository?.on, false }}"
}For Java beans, ?.on resolves either a public on field or a zero-argument
getOn() / isOn() accessor. The same operator safely calls methods:
{
"value": "{{ default .repository?.foo('bar'), false }}"
}If no method matches the supplied arguments, the call resolves as null. Safe
access does not hide exceptions thrown by an existing property accessor or a
matching method.
(These remain global, without namespace.)
not(value)— Boolean inversioneq(a, b),neq(a, b)— Equality checkslt(a, b),le(a, b),gt(a, b),ge(a, b)— Comparisonsand(a, b),or(a, b),xor(a, b)— Logical operations
-
All functions can be used in pipe form, e.g.
{ "upperName": "{{ .name | string:upper }}" } -
Namespace separation ensures no name collisions and improves clarity.
-
default,and, andorevaluate their arguments lazily.defaultevaluates its fallback only when the input isnull;andandoruse boolean short-circuit evaluation. -
Custom
TemplateFunctionimplementations can opt into the same behavior by overridingisLazy()and accessing only the arguments they need from the suppliedList.
JJTemplate supports inline conditional expressions using the ternary operator:
condition ? valueIfTrue : valueIfFalse
The operator evaluates the condition and returns one of two values:
- If the condition is true, the expression before the colon (
:) is returned. - If the condition is false or
null, the expression after the colon is returned.
{
"status": "{{ eq .ge 18 ? 'adult' : 'minor' }}"
}If .age >= 18, the result will be:
{
"status": "adult"
}Otherwise:
{
"status": "minor"
}Both condition and results (valueIfTrue / valueIfFalse)
can contain any expression, including function calls and pipes:
{
"greeting": "{{ .isMorning ? string:upper 'good morning' : string:upper 'good evening' }}"
}or with pipe syntax:
{
"formatted": "{{ .amount | gt 1000 ? 'large' : 'small' }}"
}Ternary expressions can be nested for compact logic:
{
"label": "{{ eq .type 'a' ? 'Alpha' : eq .type 'b' ? 'Beta' : 'Other' }}"
}See more examples here.
JJTemplate is built with a modular architecture:
- Lexer - Tokenizes template strings
- Parser - Constructs AST from tokens
- Compiler - Generates executable node trees
- Optimizer - Applies performance optimizations
- Runtime - Executes templates and produces output
- Minimal render time through AST optimization
- Clean separation of parsing, compilation, and execution
- Predictable output with JSON compatibility guarantees
- Optimized performance at every processing stage