A Java 21 library that compiles JSONata expressions into native Java classes at runtime. Each expression is parsed, optimised, and translated to Java source, which is then compiled in-memory and returned as a ready-to-call JsonataExpression instance.
Repeated evaluation of a JsonataExpression instance is significantly faster than interpreter-based alternatives — around 56× faster than JSONata4Java on a realistic analytical benchmark.
All test cases from the official JSONata test suite pass.
This is the compiler behind valem.run's reactive engine — every derived field in its 100+ live tax and cost models is a JSONata expression compiled through this library.
The same pipeline exists for JavaScript and Python — see Sibling implementations.
| Requirement | Version |
|---|---|
| Java | 21 (JDK — a JRE is not sufficient; the in-memory compiler needs javac) |
| Jackson Databind | 2.18+ |
| joni | 2.2+ (Oniguruma regex engine — used for /pattern/flags literals and the $match, $replace, $split, $contains functions) |
<dependency>
<groupId>io.github.vlad-public-code</groupId>
<artifactId>jsonata-jvm-compiler</artifactId>
<version>1.0.7</version>
</dependency>JsonataExpressionFactory factory = new JsonataExpressionFactory();
JsonataExpression expr = factory.compile("Account.Order.Product.Price * 1.2");compile() runs the full pipeline once and returns a reusable, thread-safe object. Compile expressions at startup and reuse them for every request — do not call compile() on the hot path.
When you need to compile a group of expressions up front (e.g. every derivation, constraint, and effect of a model at registration time), use compileAll instead of calling compile in a loop:
List<JsonataExpression> exprs = factory.compileAll(List.of(
"Account.Order.Product.Price * 1.2",
"$sum(items.price)",
"status = \"active\""));compileAll returns one JsonataExpression per input, in order, and each behaves exactly as if produced by compile. The difference is cost: the pipeline runs the expensive javac step once for the whole batch rather than once per expression. That step is dominated by a fixed per-invocation overhead (compiler bootstrap, platform symbol loading, classpath indexing) that a single small generated class barely adds to, so batching many expressions is dramatically faster than compiling them one at a time — around 11× for 20 expressions in the project's own benchmark (JsonataBatchCompilationPerfTest). The saving is one fixed javac cost per batch instead of one per expression, so how large it is depends on how many expressions the batch holds. Parsing and translation still happen per expression, so a syntactically invalid entry is reported with its index; any failure aborts the whole batch with a JsonataCompilationException.
ObjectMapper mapper = new ObjectMapper();
JsonNode input = mapper.readTree("""
{
"Account": {
"Order": {
"Product": { "Price": 50.0 }
}
}
}
""");
JsonNode result = expr.evaluate(input); // → 60evaluate() accepts a Jackson JsonNode and returns a JsonNode. The same JsonataExpression instance can be evaluated concurrently from multiple threads.
| Exception | When thrown |
|---|---|
JsonataCompilationException |
compile() — the expression is syntactically invalid or (rarely) the generated code fails to compile |
JsonataEvaluationException |
evaluate() — the input is not valid JSON, or the expression cannot be applied to it (type mismatch, division by zero, etc.) |
try {
JsonataExpression expr = factory.compile(expression);
JsonNode result = expr.evaluate(json);
} catch (JsonataCompilationException e) {
// bad expression — e.getCause() is a ParseException with source position
} catch (JsonataEvaluationException e) {
// bad input JSON or runtime error
}The library implements all JSONata language features, functions as first-class values included: a function can be stored in a variable, put in an array or object, passed to and returned from another function, and carried across the binding boundary in either direction — see Functions as values.
Bindings let you inject named values and Java functions into an expression at runtime. Inside the expression they are referenced as $name (values) or called as $name(args...) (functions).
Pass a JsonataBindings instance as the second argument to evaluate() to supply values or functions for a single call:
JsonataExpression expr = factory.compile("$taxRate * subtotal");
ObjectMapper mapper = new ObjectMapper();
JsonNode input = mapper.readTree("{\"subtotal\": 500}");
JsonNode taxRate = mapper.readTree("0.2");
JsonataBindings bindings = new JsonataBindings()
.bindValue("taxRate", taxRate);
JsonNode result = expr.evaluate(input, bindings); // → 100Per-evaluation bindings are not stored on the expression instance and do not affect other calls.
Use assign() and registerFunction() to attach bindings permanently to an expression instance. They apply to every subsequent evaluate() call.
JsonataExpression expr = factory.compile("$round2($taxRate * subtotal)");
// Permanent value
ObjectMapper mapper = new ObjectMapper();
expr.assign("taxRate", mapper.readTree("0.2"));
// Permanent function
expr.registerFunction("round2", new JsonataBoundFunction() {
@Override
public String getFunctionSignature() { return "<n:n>"; }
@Override
public JsonNode apply(JsonataFunctionArguments args) {
double v = args.get(0).doubleValue();
return new DoubleNode(Math.round(v * 100.0) / 100.0);
}
});
JsonNode r1 = expr.evaluate(mapper.readTree("{\"subtotal\": 100}")); // → 20.0
JsonNode r2 = expr.evaluate(mapper.readTree("{\"subtotal\": 333}")); // → 66.6Permanent bindings are isolated per instance — assigning to one JsonataExpression does not affect any other.
When both a permanent binding and a per-evaluation binding exist for the same name, the per-evaluation binding wins.
A bound function is not only callable — $name on its own is a function value, so it can be
passed to a higher-order built-in, piped through ~>, or handed to another bound function:
JsonataBindings bindings = new JsonataBindings().bindFunction("double", doubler);
factory.compile("$map([1,2,3], $double)").evaluate(input, bindings); // → [2, 4, 6]
factory.compile("5 ~> $double").evaluate(input, bindings); // → 10
factory.compile("$type($double)").evaluate(input, bindings); // → "function"This is what makes a library export usable as an argument as well as a call
target, since exports are supplied through registerFunction.
The reverse also holds: a function value can be bound with bindValue and called by name.
JsonataRuntime.lambdaNode builds one from a Java lambda, with the number of parameters it takes:
JsonNode timesTen = JsonataRuntime.lambdaNode(x -> new DoubleNode(x.doubleValue() * 10), 1);
JsonataBindings bindings = new JsonataBindings().bindValue("f", timesTen);
factory.compile("$f(3)").evaluate(input, bindings); // → 30.0
factory.compile("$map([1,2], $f)").evaluate(input, bindings); // → [10.0, 20.0]Both maps are consulted, and the one that matches the position wins: $name in value position
prefers a value binding, $name(...) at a call site prefers a function binding.
Arity. How many arguments reach a bound function used as a value is decided by its declared
signature — <nn:b> makes a two-argument function, so $sort([2,3,1], $desc) receives a comparator
pair and $map supplies the index. A signature that does not pin the arity down (absent,
unparseable, or variadic) yields a one-argument function value. This is the same limitation
hand-written JSONata lambdas have: a packed argument tuple is an array, and so is a single array
argument. Declare a fixed arity to receive several arguments.
JsonataBoundFunction has two methods:
| Method | Purpose |
|---|---|
String getFunctionSignature() |
Describes the expected argument types and return type (see signature syntax below) |
JsonNode apply(JsonataFunctionArguments args) |
Executes the function; may throw JsonataEvaluationException |
JsonataFunctionArguments wraps the argument list. Accessing an out-of-range index returns MissingNode rather than throwing.
The signature has the form <params:return> where params is a sequence of type symbols and return is a single type symbol.
Simple types
| Symbol | Type |
|---|---|
b |
Boolean |
n |
number |
s |
string |
l |
null |
Complex types
| Symbol | Type |
|---|---|
a |
array |
o |
object |
f |
function |
j |
any JSON type — equivalent to (bnsloa) |
u |
Boolean, number, string, or null — equivalent to (bnsl) |
x |
any type at all, functions included — equivalent to (bnsloaf) |
(sao) |
union: string, array, or object |
Parametrised types: a<s> (array of strings), a<x> (array of any type), f<n:n> (a function
from number to number). A parametrised f requires a function, but the argument function's own
parameter and return types are not checked — jsonata-js does not check them either.
An argument declared f that is not a function is rejected with T0410. Note that j is documented
by the JSONata spec as excluding functions but does not reject one here; declare f when you
require a function.
Option modifiers appended to a type symbol:
| Modifier | Meaning |
|---|---|
+ |
One or more arguments of this type (variadic) |
? |
Optional argument |
- |
Use the context value ("focus") if the argument is missing |
Example: $length has signature <s-:n> — accepts a string (using context as focus if omitted) and returns a number.
The bindings above are written in Java: a JsonataBoundFunction per function, an assign per value, repeated for every expression that needs them. A library is the same set of bindings written in JSONata instead — once, in one file — and applied to any expression that needs it.
A library is nothing more than a definition expression: ordinary JSONata that binds names and returns the names to export.
(
$vatRate := 0.2;
$round2 := function($n){ $round($n, 2) };
$gross := function($net){ $round2($net * (1 + $vatRate)) };
$format := function($n){ "£" & $string($round2($n)) };
["gross", "format", "vatRate"]
)
That is a complete, valid JSONata expression. Evaluate it in any JSONata engine and it returns ["gross", "format", "vatRate"] — the export list is the expression's result, not a parameter passed from Java. So a definition file can be linted, tested and run by tools that know nothing about this library, and it states its own interface: nothing outside it decides what it provides.
JsonataLibrary billing = factory.compileLibrary(definition);
billing.getFunctions(); // Map<String, JsonataBoundFunction> — gross, format
billing.getConstants(); // Map<String, JsonNode> — vatRateEach exported name lands in one map or the other according to what it evaluated to — the definition never says which is which. Names it binds but does not export ($round2 here) stay private, while remaining reachable from the exported functions.
useLibrary applies a whole library, functions and constants together, so the caller never has to
know which name is which. On the expression it is permanent, for the lifetime of that instance:
JsonataExpression invoice = factory.compile("lines.$gross(amount) ~> $sum() ~> $format()");
invoice.useLibrary(billing);or per evaluation, when different calls need different libraries:
JsonataBindings bindings = new JsonataBindings()
.useLibrary(billing);
invoice.evaluate(input, bindings);It returns the same JsonataBindings, so libraries and one-off bindings compose in a single
expression:
JsonataBindings bindings = new JsonataBindings()
.useLibrary(billing)
.useLibrary(formatting)
.bindValue("today", today);Applying two libraries that export the same name leaves the later one in place, exactly as re-binding a name always does.
Either way the expression sees $gross(...), $format(...) and $vatRate exactly as if they had been written in Java — the precedence rules above apply unchanged, so a per-evaluation binding still wins over a library one registered permanently.
Applying a library to every expression in an application is one line each:
for (JsonataExpression expr : factory.compileAll(expressions)) {
expr.useLibrary(billing);
}Anything JSONata can express. Exported functions may be recursive, mutually recursive, closures over private helpers, λ-notation, functions returned by other functions, ~> chains, or partial applications:
(
$pi := 3.1415926535897932384626;
/* private helpers — not exported, still reachable */
$product := function($a, $b) { $a * $b };
$factorial := function($n) { $n = 0 ? 1 : $reduce([1..$n], $product) };
$sin := function($x){ $cos($x - $pi/2) };
$cos := function($x){
$x > $pi ? $cos($x - 2 * $pi) : $x < -$pi ? $cos($x + 2 * $pi) :
$sum([0..12].($power(-1, $) * $power($x, 2*$) / $factorial(2*$)))
};
["sin", "cos", "pi"]
)
Constants are values, not expressions: the definition runs once, when the library is compiled, so $total := $sum([1..10]) exports the number 55. Functions, by contrast, run whenever they are called.
The export list is itself an expression — ["sin", "cos"] is the usual form, a single "sin" works, and so does a list computed at definition time. A definition that forgets its export list ends on its last binding and therefore returns a function; that is rejected with must return an array of function names.
Exported functions can also be called straight from Java, with no expression involved:
JsonataBoundFunction gross = billing.getFunctions().get("gross");
JsonNode result = gross.apply(new JsonataFunctionArguments(List.of(DoubleNode.valueOf(100))));Each exported function reports a JSONata signature:
| Definition | Reported signature |
|---|---|
$twice := function($x)<n:n>{ $x * 2 } |
<n:n> — the declared one |
$volume := function($l, $w, $h){ ... } |
<j?j?j?:j> — synthesised, all-optional |
$normalize := $uppercase ~> $trim |
none — arity known only at call time |
The synthesised form is deliberately permissive: JSONata lets a lambda be called with fewer arguments than it declares (the rest are undefined), and j applies no coercion — so an exported function accepts exactly what the same function accepts inside JSONata. Ask for something stricter with a signature override:
JsonataLibrary lib = factory.compileLibrary(definition,
new JsonataLibraryOptions().signature("$gross", "<n:n>"));
// "<n:n>" coerces at the boundary: $gross("100") worksA library owns one generated class, so build it once at startup and keep it — the same advice as compile(). Exported functions are thread-safe and may be called concurrently.
JsonataLibrary is AutoCloseable; close() retires the exported functions (calling one afterwards throws JsonataEvaluationException), which is only worth doing when the lifetime should be explicit. Constants keep working — they are ordinary nodes. Letting the library become unreachable releases everything.
JsonataLibraryOptions also carries the document the definition is evaluated against (input, for a definition that reads from data) and the bindings visible while it runs (bindings).
Every name a definition uses has to come from somewhere it controls: a name it binds itself, a JSONata built-in, or a name handed to it at build time. Anything else is rejected when the library is compiled:
($withVat := function($net){ $net * (1 + $vatRate) }; ["withVat"])
→ JsonataCompilationException: The definition expression uses $vatRate, which it does not bind
and which is not a JSONata built-in. Bind it in the definition, or supply it through
JsonataLibraryOptions.bindings.
The alternative — resolving $vatRate against whatever happens to be bound where $withVat is called — would make a library's behaviour depend on its caller, and would make a typo ($rat for $rate) indistinguishable from a deliberate hook. Failing at build time names both the problem and the fix.
To parameterise a library, supply the values when you build it:
JsonataLibrary lib = factory.compileLibrary(definition,
new JsonataLibraryOptions().bindings(
new JsonataBindings().bindValue("vatRate", rate)));Those names are then in scope for the definition, and are captured by the functions it exports.
Lambda parameters, bindings inside nested blocks, forward references between siblings (mutual recursion), and path bindings (@$v, #$i) all count as bound — only genuinely unresolvable names are reported.
One further semantic worth knowing: the caller's evaluation is reused. Called from inside an expression, an exported function shares that evaluation's recursion budget (100 nested calls) and its setTimeout deadline.
Call setTimeout(int timeoutMs) on an expression instance to cap how long a single evaluate() call may run. If the deadline is exceeded, a JsonataEvaluationException with error code U1001 is thrown.
JsonataExpression expr = factory.compile("...");
expr.setTimeout(500); // 500 ms wall-clock limit per evaluate() call
try {
JsonNode result = expr.evaluate(input);
} catch (JsonataEvaluationException e) {
if ("U1001".equals(e.getErrorCode())) {
// evaluation exceeded 500 ms
}
}Pass 0 or a negative value to remove the timeout. The timeout applies to all future evaluate() calls on the instance; concurrent calls on the same instance each track their own independent deadline. Setting a timeout has no measurable overhead on evaluations that complete before the deadline.
JsonataExpression expr = factory.compile("$sum(items.price)");
System.out.println(expr.getSourceJsonata()); // → "$sum(items.price)"Use the lower-level API to obtain the generated source before compilation:
import org.json_kula.jsonata_jvm.parser.Parser;
import org.json_kula.jsonata_jvm.optimizer.Optimizer;
import org.json_kula.jsonata_jvm.translator.Translator;
import org.json_kula.jsonata_jvm.parser.ast.AstNode;
AstNode ast = Optimizer.optimize(Parser.parse("price * qty"));
String javaSource = Translator.translate(ast, "com.example.gen", "PriceExpression", "price * qty");
System.out.println(javaSource);If you have previously generated and saved a Java source string, compile it directly without re-parsing:
import org.json_kula.jsonata_jvm.loader.JsonataExpressionLoader;
JsonataExpressionLoader loader = new JsonataExpressionLoader();
JsonataExpression expr = loader.load(javaSource);jsonata-jvm-compiler compiles expressions to native JVM bytecode, so repeated evaluation is significantly faster than interpreter-based alternatives.
Benchmark: jsonata-jvm-compiler vs JSONata4Java
The benchmark compiles one expression once, then runs 100,000 evaluations against the same JSON document (with a 1,000-evaluation JVM warmup before timing). The expression is a realistic analytical query covering variable bindings, nested field navigation, array filtering, aggregation functions ($sum, $count, $average, $max, $min, $distinct), string operations, arithmetic, and a conditional.
Measured on OpenJDK 21 (Temurin 21.0.10), Windows 11. The figures come from the side-by-side test, which warms up and times both libraries in one JVM. Re-measured 2026-09-19 after the code-review fixes: eight runs gave 76,796 / 80,122 / 82,214 / 87,634 / 91,789 / 93,316 / 95,759 eval/s and 50.3× / 52.2× / 53.3× / 53.6× / 55.4× / 60.1× / 60.5× / 63.8×, a mean of 56×. Treat the round numbers as the useful precision.
The speedup does not track our own throughput exactly, because the ratio moves with JSONata4Java's as well: its 1,506–1,583 eval/s across the same runs is the steadier of the two, but it is not constant, so the fastest run here is not quite the highest ratio.
| Metric | jsonata-jvm-compiler | JSONata4Java |
|---|---|---|
| Compilation | ~840–1,120 ms | ~150–320 ms |
| 100,000 evaluations | ~1,040–1,300 ms | ~63,200–66,400 ms |
| Throughput | ~82,000–93,000 eval/s | ~1,500–1,580 eval/s |
| Speedup | ~53×–61× faster | baseline |
These absolute figures are lower than the 131,000–137,000 eval/s recorded on 2026-09-05, and that is this machine rather than the code: run in the same session, the unchanged pre-fix build measured 82,817 / 88,476 / 89,524 eval/s, and JSONata4Java moved with it (1,506–1,583 eval/s against 2,302–2,392). The ratio, which cancels the drift, is unchanged.
The code-review fixes are throughput-neutral, by the paired A/B this project uses for attribution: three pairs alternating the pre-fix and fixed builds in one session gave means of 86,900 and 87,700 eval/s, with the direction flipping between pairs (base ahead in the first, fixed ahead in the second, level in the third). That is noise, not a gain — which is the claim being made, since the fixes were made for correctness. The earlier sequence-scan fusion gain (+5% to +17%, same direction in all six runs) was measured the same way and still stands.
Compilation is a one-time cost paid at startup. For any workload that reuses an expression more than a handful of times the throughput advantage dominates. Compiling several expressions? Use
compileAll— onejavacinvocation for the batch instead of one per expression, worth around 11× for 20 expressions.
Where the speed comes from, beyond compiling to bytecode: literal values are hoisted to static fields rather than rebuilt inside every loop; object constructors with literal keys are filled into two parallel arrays with no hashing and no duplicate check, since the compiler already knows the keys are distinct; common aggregate shapes ($count(x[field = "value"]), $sum(x.field)) are fused into a single loop with no intermediate sequence; the several operations a block performs over one sequence are then fused again into a single pass that reads each field once per element rather than once per operation; and the runtime's hot type checks are single dispatches rather than chains of megamorphic calls.
The benchmark is reproducible via:
mvn test -Dtest=PerformanceComparisonTest -DargLine="-Djunit.jupiter.conditions.deactivate=org.junit.jupiter.engine.extension.DisabledCondition"
The benchmark class carries @Disabled so that a normal mvn test does not spend minutes on it, which is why the run needs that condition switched off — selecting the test with -Dtest= alone silently skips it.
A JsonataExpressionFactory instance and all JsonataExpression instances it produces are fully thread-safe. evaluate() is stateless — each call processes the input JSON independently and returns a new JsonNode without modifying any shared state.
// Compile once at startup
JsonataExpression totalPrice = factory.compile("$sum(items.(price * qty))");
// Call concurrently from any number of threads
ExecutorService pool = Executors.newFixedThreadPool(16);
pool.submit(() -> totalPrice.evaluate(requestJson));expression string
│
▼
Parser.parse() → AstNode (sealed interface hierarchy)
│
▼
Optimizer.optimize() → AstNode (constant-folded, simplified)
│
▼
Translator.translate() → Java 21 source string
│
▼
JsonataExpressionLoader.load() → JsonataExpression (compiled, in-memory)
│
▼
expr.evaluate(json) → JsonNode
JsonataExpressionFactory.compile() runs this entire pipeline in a single call.
| Package | Contents |
|---|---|
org.json_kula.jsonata_jvm |
Public API: JsonataExpression, JsonataExpressionFactory, JsonataBindings, JsonataBoundFunction, JsonataFunctionArguments, JsonataCompilationException, JsonataEvaluationException |
org.json_kula.jsonata_jvm.parser |
Parser, ParseException |
org.json_kula.jsonata_jvm.parser.lexer |
Lexer, Token, TokenType |
org.json_kula.jsonata_jvm.parser.ast |
AstNode sealed interface with all node types and Visitor |
org.json_kula.jsonata_jvm.optimizer |
Optimizer |
org.json_kula.jsonata_jvm.translator |
Translator |
org.json_kula.jsonata_jvm.runtime |
JsonataRuntime (static helper methods), JsonataLambda |
org.json_kula.jsonata_jvm.loader |
JsonataExpressionLoader, JsonataLoadException |
The same parse → optimise → translate → compile pipeline exists for three host runtimes:
| Runtime | Project | Host code it generates | Speedup vs. that runtime's reference interpreter |
|---|---|---|---|
| JVM | jsonata-jvm-compiler (this project, Java 21) — docs · Maven Central · source | Java source, compiled in-memory by javac |
~56× vs JSONata4Java |
| JavaScript | jsonata2js — docs · npm · source | a JS function, loaded with new Function |
~60× vs jsonata |
| Python | jsonata2py — docs · PyPI · source | Python source, compiled by the host compile() |
~61× vs jsonata-python |
Each figure is the one that project measures against its own runtime's reference interpreter, on its own benchmark and its own hardware; they are not comparable across rows. All three pass the official JSONata test suite.
The JVM implementation is the original, and is the compiler behind valem.run's reactive computation engine.
Conformance is also worked on across all three together: when one port disagrees with the reference (jsonata 2.2.2, the arbiter), the other two are run before concluding anything, because "every reimplementation gets this wrong" is a different finding with a different fix from "this port gets this wrong". That has repeatedly changed the diagnosis — and each port's write-up has corrected an earlier one's.
This project is licensed under the Apache License 2.0.