Introducing Corvus.Text.Json V5: JSONata - Query and Transform JSON
At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at the standalone evaluator and annotations.
Now we're moving on to query languages. V5 includes three, and we'll start with JSONata, the most expressive of the trio.
What is JSONata?
JSONata is a functional query and transformation language for JSON. It gives you path navigation, filtering, higher-order functions ($map, $filter, $reduce, $sort), object construction, string manipulation, arithmetic, regex, and user-defined functions. It's Turing-complete, and it's the standard expression language for tools like Node-RED and IBM Cloud Pak.
The Corvus implementation passes all 1,665 official test suite cases. It achieves 100% conformance on both .NET 10.0 and .NET Framework 4.8.1.
Quick start
Install the packages:
dotnet add package Corvus.Text.Json
dotnet add package Corvus.Text.Json.Jsonata
The simplest approach is string in, string out:
using Corvus.Text.Json.Jsonata;
string? result = JsonataEvaluator.Default.EvaluateToString(
"FirstName & ' ' & Surname",
"""{"FirstName": "Fred", "Surname": "Smith", "Age": 28}""");
Console.WriteLine(result); // "Fred Smith"
That's three lines of code to evaluate a JSONata expression, with no document model, workspace management, or disposal to worry about.
Zero-allocation evaluation
For production code where you control the lifetime of parsed documents, use the full API:
using Corvus.Text.Json;
using Corvus.Text.Json.Jsonata;
using var dataDoc = ParsedJsonDocument<JsonElement>.Parse(
"""
{
"FirstName": "Fred",
"Surname": "Smith",
"Age": 28,
"Address": { "City": "London" }
}
""");
JsonElement result = JsonataEvaluator.Default.Evaluate(
"FirstName & ' ' & Surname",
dataDoc.RootElement);
Console.WriteLine(result.GetRawText()); // "Fred Smith"
On subsequent calls with the same expression, the compiled delegate tree is cached internally, so you get zero-allocation evaluation from the second call onward.
If you want explicit control over the workspace that pools intermediate memory, pass one in:
using JsonWorkspace workspace = JsonWorkspace.Create();
JsonElement result = JsonataEvaluator.Default.Evaluate(
"FirstName & ' ' & Surname",
dataDoc.RootElement,
workspace);
Practical example: reshaping API responses
JSONata really shines when you need to reshape data. Say you get a complex employee record from an API and need a simpler structure:
{
"name": FirstName & " " & Surname,
"mobile": Contact.Phone[type = "mobile"].number,
"city": Address.City
}
This navigates nested objects, filters arrays by a predicate, and constructs a new shape. It all happens in a single expression. In C#:
string expression = """
{
"name": FirstName & " " & Surname,
"mobile": Contact.Phone[type = "mobile"].number,
"city": Address.City
}
""";
string? result = JsonataEvaluator.Default.EvaluateToString(
expression,
employeeJson);
Evaluating to a strongly-typed result
EvaluateToString is convenient for interop with existing code, but the real power is evaluating directly to a generated JSON type. The generic Evaluate<T> overload returns the result as any schema-generated type:
// ContactSummary is generated from a JSON Schema:
// { "type": "object", "properties": {
// "name": { "type": "string" },
// "mobile": { "type": "string" },
// "city": { "type": "string" }
// }}
using var dataDoc = ParsedJsonDocument<JsonElement>.Parse(employeeJson);
using JsonWorkspace workspace = JsonWorkspace.Create();
ContactSummary summary = JsonataEvaluator.Default.Evaluate<ContactSummary>(
expression,
dataDoc.RootElement,
workspace);
// Strongly-typed access - no casting, no string parsing
string name = (string)summary.Name;
string city = (string)summary.City;
// Schema validation is built in
bool isValid = summary.EvaluateSchema();
The JSONata expression reshapes the data, and the generated type gives you compile-time safety over the result. You can validate the output against its schema, pass it to other V5 APIs, mutate it with a builder, or serialize it directly.
Three modes of evaluation
1. Interpreted (runtime)
Best when expressions are determined at runtime. Use this for user-supplied queries, configuration-driven transforms, or any scenario where the expression isn't known at compile time.
2. Source generator
When expressions are known at build time, the source generator compiles them to optimized static C#:
using Corvus.Text.Json.Jsonata;
namespace MyApp.Expressions;
[JsonataExpression("Expressions/full-name.jsonata")]
public static partial class FullNameExpression;
The expression file (full-name.jsonata) contains the JSONata expression and is registered as an AdditionalFiles item in your project. The generated code eliminates delegate dispatch and enables constant folding. Pure arithmetic like 1 + 2 * 3 is folded to a literal value at compile time, giving sub-nanosecond "evaluation."
3. CLI code generation
For expressions managed outside the build pipeline:
corvusjson jsonata Expressions/full-name.jsonata \
--className FullNameExpression \
--namespace MyApp.Expressions \
--outputPath Generated/
Performance
Here are some representative benchmarks comparing the Corvus interpreted runtime, code-generated evaluator, and Jsonata.Net.Native v3.0.0 (the reference .NET implementation). All measured on .NET 10.0:
Employee transform (multi-step expression)
{
"name": Employee.FirstName & " " & Employee.Surname,
"mobile": Contact.Phone[type = "mobile"].number
}
| Method | Mean | Allocated |
|---|---|---|
| Corvus (interpreted) | 1,658 ns | 960 B |
| Corvus (code-gen) | 1,585 ns | 240 B |
| Jsonata.Net.Native | 3,032 ns | 9,920 B |
The interpreted evaluator is 1.8× faster with 90% less allocation. Code-gen is 1.9× faster with 97% less allocation.
Property navigation and math
| Scenario | Corvus RT | Code-Gen | Jsonata.Net.Native | RT Alloc | Native Alloc |
|---|---|---|---|---|---|
| Deep path | 650 ns | 458 ns | 544 ns | 120 B | 1,816 B |
| Array index | 87 ns | 79 ns | 317 ns | 0 B | 1,408 B |
| $round | 461 ns | 298 ns | 936 ns | 0 B | 3,224 B |
| $contains | 252 ns | 153 ns | 895 ns | 0 B | 3,136 B |
| String concat | 366 ns | 177 ns | 332 ns | 0 B | 1,408 B |
Across 32 benchmarks, the geometric mean speedup is approximately 2× for the interpreted evaluator, with 90–100% less memory in almost every scenario. The memory reduction is the stronger headline. Most benchmarks show zero allocation where the reference implementation allocates 1–10 KB.
The full benchmark suite has 95 scenarios. We quote the geometric mean rather than the maximum because individual expression patterns vary considerably. You can run the benchmarks yourself from the repository.
Try it in your browser
The JSONata Playground lets you experiment with expressions using the Corvus interpreted runtime - no installation needed.
Next up
In the next post, we'll look at JMESPath - a simpler, standardised query language used by AWS CLI and Azure CLI, where the performance story is even more dramatic.