Introduction
datalogic-rs is a JSONLogic rules engine: one Rust core with official bindings for Rust, Node.js, the browser (WASM), Python, Go, Java, .NET, and PHP, plus a React visual debugger. Rules are plain JSON; the same rule evaluates with identical semantics in every runtime, verified by a 2,128-case conformance battery that runs against the same core every binding ships.
This site is the reference documentation. For the project pitch, benchmarks, and package matrix, see the GitHub repository; to try rules in your browser, open the playground.
What is JSONLogic?
JSONLogic is a standard for expressing logic rules as JSON. A rule is:
- Portable: you store it in a database, send it over an API, or embed it in configuration
- Language-agnostic: the same rule runs on any conforming implementation
- Readable: a reviewer reads the rule itself, with no host code around it
- Safe: evaluating a rule runs no code the rule supplies
A JSONLogic rule is a JSON object where the key is the operator name and the value is an array of arguments:
{"operator": [arg1, arg2, ...]}
For example:
{"and": [
{">": [{"var": "age"}, 18]},
{"==": [{"var": "country"}, "US"]}
]}
This rule checks that age > 18 and country == "US".
How the engine works
datalogic-rs works in two phases:
-
Compilation: the engine parses the rule and compiles it into a reusable
Logic. This phase:- assigns each built-in operator an OpCode, and each custom operator a slot on the engine
- evaluates constant subexpressions once
- builds output templates in templating mode
-
Evaluation: the engine runs the compiled logic against your data with:
- OpCode dispatch, with no operator-name lookup at runtime
- arena-allocated results that can borrow from the input without copying
- a context stack for iterator bodies (
map,filter,reduce)
Every binding exposes this compile-once, evaluate-many pattern; Performance has the numbers.
Before a rule runs, you can validate it statically: Engine::check (check in every binding) reports unknown operators with a “did you mean” suggestion, wrong argument counts, and other mistakes—each with a JSON Pointer into the rule (Rule Analysis). The MissingVar::Error setting turns a read of a misspelled data path into a VariableNotFound error instead of null (Configuration), ops_budget bounds untrusted execution (Operation Budget), and Engine::operators() lists every operator compiled into your build (API Reference).
Find your language
Each language has its own chapter with install steps, a quick start, and the full API surface:
| Your stack | Start here |
|---|---|
| Rust | Rust (native crate) |
| Node.js services | Node.js (native) · Integration: Express |
| Browser, edge, Deno, Bun | JavaScript (WASM) · Browser & Frameworks |
| Python | Python · Quick Start |
| Go | Go · Quick Start |
| Java, Kotlin, Scala | Java / Kotlin (JVM) · Integration: Spring Boot |
| .NET (C#, F#) | .NET |
| PHP | PHP |
| Any other language | C ABI |
| React rule-builder UI | React Visual Debugger |
How these docs are organized
- Getting Started: installation, first evaluation, core concepts, and starter service boilerplates
- Operators: reference for all 84 built-in operators, with runnable examples on every page
- Languages: one chapter per binding (see the table above)
- Guides: custom operators, configuration, rule analysis, operation budget, structured objects / templating, thread safety, security & sandboxing, and service integrations (Express, Spring Boot)
- Reference: use-case cookbook, performance, comparisons, migration, FAQ, and troubleshooting
Next steps
- Installation: add
datalogic-rsto your project - Quick Start: run your first evaluation
- Basic Concepts: understand compilation, sessions, and evaluation
- Starter Boilerplates: copy-paste Express, FastAPI, and Axum services
- Use Cases & Examples: feature flags, pricing, validation, and fraud scoring
- Coming from json-logic-js?: run your existing rules unchanged
- Migration Guide: upgrade from v4 or review behavior changes across 5.x releases
Playground (inline)
The Full-Page Visual Editor adds visual editing, engine settings and resizable panels. This page embeds the same editor inline, with a read-only diagram, for quick checks.
Try JSONLogic expressions in your browser. This playground runs the @goplasmatic/datalogic-ui debugger on the datalogic-rs 5.9.0 engine, compiled to WebAssembly.
How to Use
- Logic: Enter your JSONLogic expression in the Logic panel
- Data: Enter the JSON data to evaluate against in the Data panel
- Result: Read the evaluated output, or the engine’s error with its type and failing operator
- Diagram: View the diagram of your logic expression and step through the evaluation with the debugger controls (Space, arrow keys, Home/End once you click the diagram)
- Examples: Use the Load Example… dropdown to load pre-built examples
- Templating: Tick the checkbox to compile multi-key objects as output templates with embedded JSONLogic
Quick Reference
Basic Operators
| Operator | Example | Description |
|---|---|---|
var | {"var": "x"} | Access variable |
== | {"==": [1, 1]} | Equality |
>, <, >=, <= | {">": [5, 3]} | Comparison |
and, or | {"and": [true, true]} | Logical |
if | {"if": [cond, then, else]} | Conditional |
+, -, *, / | {"+": [1, 2]} | Arithmetic |
Array Operations
| Operator | Example | Description |
|---|---|---|
map | {"map": [arr, expr]} | Transform elements |
filter | {"filter": [arr, cond]} | Filter elements |
reduce | {"reduce": [arr, expr, init]} | Reduce to value |
all, some, none | {"all": [arr, cond]} | Check conditions |
String Operations
| Operator | Example | Description |
|---|---|---|
cat | {"cat": ["a", "b"]} | Concatenate |
substr | {"substr": ["hello", 0, 2]} | Substring |
in | {"in": ["@", "a@b.com"]} | Contains |
Example: Feature Flag
Determine if a user has access to a premium feature:
{
"and": [
{"==": [{"var": "user.plan"}, "premium"]},
{">=": [{"var": "user.accountAge"}, 30]}
]
}
Data:
{
"user": {
"plan": "premium",
"accountAge": 45
}
}
Example: Dynamic Pricing
Calculate a discounted price based on quantity:
{
"if": [
{">=": [{"var": "quantity"}, 100]},
{"*": [{"var": "price"}, 0.8]},
{"if": [
{">=": [{"var": "quantity"}, 50]},
{"*": [{"var": "price"}, 0.9]},
{"var": "price"}
]}
]
}
Data:
{
"quantity": 75,
"price": 100
}
Learn More
- Operators Overview: Full operator documentation
- Getting Started: Using the library in your stack
- React Visual Debugger: Embed this visual editor and debugger into your own React app
- Use Cases & Examples: Real-world recipes for feature flags, pricing, and validation
Installation
Adding to Your Project
Pick your language. Each package carries the core crate’s version number (5.9.0 for this release):
// Cargo.toml
[dependencies]
datalogic-rs = "5"
Or run in terminal:
cargo add datalogic-rs
// npm
npm install @goplasmatic/datalogic-node # for Node.js services (native FFI)
# or:
npm install @goplasmatic/datalogic-wasm # for Browsers / Bun / Workers (WASM)
# pip
pip install datalogic-py
// go.mod
go get github.com/GoPlasmatic/datalogic-rs/bindings/go/v5
// Maven: pom.xml
<dependency>
<groupId>io.github.goplasmatic</groupId>
<artifactId>datalogic</artifactId>
<version>5.9.0</version>
</dependency>
// Gradle: build.gradle.kts
implementation("io.github.goplasmatic:datalogic:5.9.0")
// dotnet CLI
dotnet add package Goplasmatic.Datalogic
// Composer
composer require goplasmatic/datalogic
The Rust crate builds without serde_json by default: the main entry
points (Engine::eval_str, Engine::compile(&str),
datalogic_rs::eval_str) take and return JSON text. Add the serde_json
feature if you need serde_json::Value interop or the typed
eval_into::<T> paths.
Feature Flags
The crate is a small core plus opt-in features. With default = [] you get
the 33 JSONLogic core operators and no optional dependencies:
| Feature | Default | What it adds |
|---|---|---|
serde_json | off | &serde_json::Value interop (as EvalInput / IntoLogic) and the typed eval_into::<T> paths on Engine, Session, and the module-level helpers. Pulls in serde_json as a runtime dependency. |
templating | off | Templating mode: engine-wide with Engine::builder().with_templating(true).build(), or for one rule with Engine::compile_template. |
datetime | off | datetime, timestamp, parse_date, format_date, date_diff, now operators, including the optional trailing IANA-zone argument on format_date / parse_date (pulls in chrono and chrono-tz). |
trace | off | Per-evaluation execution tracing (engine.trace()…). Transitively enables serde_json. |
ext-string | off | Extended string operators (length, starts_with, ends_with, upper, lower, trim, split). |
ext-array | off | Extended array operators (sort, slice, group_by, distinct). |
ext-object | off | Object take-apart operators (keys, values, entries). |
ext-control | off | Extended control-flow operators (exists, ??, switch/match, type). |
error-handling | off | try / throw operators. |
ext-math | off | Extended math operators (abs, ceil, floor). |
flagd | off | OpenFeature flagd-compatible fractional (murmurhash3 percentage bucketing) and sem_ver (semantic-version comparison) operators. |
wasm-clock | off | JS-host clock for the now operator on wasm32-unknown-unknown (browsers, Node, Deno, Workers); combine with datetime. It forwards to chrono/wasmbind, whose JS imports fail to instantiate in non-JS wasm runtimes such as wasmtime, wazero and Chicory, so leave it off there (on WASI the OS clock works without it). |
tensor | off | The Tensor value (dtype, shape and a row-major byte buffer) and 20 marshalling-only operators over it, for turning JSON into a model’s inputs and its outputs back into JSON. Constructors refuse more than 2^28 elements. No new dependency. |
tensor-half | off | Lifts the f16 / bf16 restriction on the element-wise tensor operators. Implies tensor; pulls in half. |
budget | off | A per-evaluation operation counter with a hard abort and usage reports (EvaluationConfig::ops_budget, Engine::evaluate_metered / Engine::evaluate_metered_report, Session::eval_metered / Session::eval_metered_report, ErrorKind::BudgetExceeded). See Operation Budget. |
all-operators | off | Every operator family at once: datetime, error-handling, the five ext-* families, flagd and tensor. A family added in a later release joins it. It does not include serde_json, templating, trace, budget, tensor-half or wasm-clock. |
Example: opt into serde_json::Value interop plus templating:
[dependencies]
datalogic-rs = { version = "5", features = ["serde_json", "templating"] }
serde_json = "1.0"
Example: every operator, plus serde_json interop and templating (the
official bindings use this set, plus trace and budget):
[dependencies]
datalogic-rs = { version = "5", features = ["all-operators", "serde_json", "templating"] }
A Cargo feature decides what is compiled in. To give one engine fewer
operators than the build has, list the families it keeps with
Engine::builder().with_families(..) (families in the bindings); see
Configuration.
Version Selection
- v5.x (current, 5.9.0): string-based API, opt-in
serde_json, builder-only operator registration. v4 code has no compatibility shim in v5, so plan a single cutover. - v4.x:
DataLogicengine with aserde_json::Value-first API. No longer the active line. - v3.x: the arena-based engine that v4 replaced.
If you’re upgrading from v4, or from an earlier 5.x release, see the Migration Guide.
Other languages
The Rust crate is the engine; each other language has its own binding over it. The binding’s guide covers install details and the API in that language:
| Language | Package | Install | Deep-dive |
|---|---|---|---|
| Node.js (native, napi-rs) | @goplasmatic/datalogic-node | npm i @goplasmatic/datalogic-node | Node.js docs |
| JavaScript / TypeScript (WASM) | @goplasmatic/datalogic-wasm | npm i @goplasmatic/datalogic-wasm | JS / TS docs |
| Python | datalogic-py | pip install datalogic-py | Python docs |
| Go | datalogic-go | go get github.com/GoPlasmatic/datalogic-rs/bindings/go/v5 | Go docs |
| JVM (Java, Kotlin, Scala) | io.github.goplasmatic:datalogic | io.github.goplasmatic:datalogic:5.9.0 (Maven Central, JDK 22+) | Java / Kotlin docs |
| .NET | Goplasmatic.Datalogic | dotnet add package Goplasmatic.Datalogic | .NET docs |
| PHP | goplasmatic/datalogic | composer require goplasmatic/datalogic | PHP docs |
| C ABI (any FFI host) | datalogic-c (in-tree, not published) | build bindings/c from source | C ABI docs |
| React (visual debugger) | @goplasmatic/datalogic-ui | npm i @goplasmatic/datalogic-ui | React docs |
Building the WASM binding from source:
cd bindings/wasm
./build.sh
Minimum Rust Version
datalogic-rs requires Rust 1.98 or later (the rust-version in its
Cargo.toml). The floor comes from the datavalue-rs 0.3 dependency,
which declares 1.98; the crate’s own code uses edition 2024 and needs
less. The crate sets #![forbid(unsafe_code)].
Verifying Installation
Run a one-line evaluation to confirm the package loads:
// main.rs
fn main() {
let result = datalogic_rs::eval_str(r#"{"+": [1, 2]}"#, r#"{}"#).unwrap();
println!("1 + 2 = {}", result);
assert_eq!(result, "3");
}
// Run in terminal: cargo run
// index.js
import { apply } from '@goplasmatic/datalogic-node';
const result = apply({ '+': [1, 2] }, {});
console.log(`1 + 2 = ${result}`); // 1 + 2 = 3
// browser/edge: same API via @goplasmatic/datalogic-wasm, see the WASM chapter
# test.py
from datalogic_py import apply
result = apply({"+": [1, 2]}, {})
print(f"1 + 2 = {result}") # 1 + 2 = 3
// main.go
package main
import (
"fmt"
datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
)
func main() {
result, _ := datalogic.Apply(`{"+": [1, 2]}`, `{}`)
fmt.Printf("1 + 2 = %s\n", result) // 1 + 2 = 3
}
// Main.java
import com.goplasmatic.datalogic.Engine;
public class Main {
public static void main(String[] args) {
try (Engine engine = new Engine()) {
String result = engine.apply("{\"+\": [1, 2]}", "{}");
System.out.println("1 + 2 = " + result); // 1 + 2 = 3
}
}
}
// Program.cs
using Goplasmatic.Datalogic;
using var engine = new Engine();
var result = engine.Apply("""{"+": [1, 2]}""", "{}");
Console.WriteLine($"1 + 2 = {result}"); // 1 + 2 = 3
<?php // test.php
require 'vendor/autoload.php';
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
echo "1 + 2 = " . $engine->apply('{"+": [1, 2]}', '{}'); // 1 + 2 = 3
Next Steps
- Quick Start: run your first one-shot and compiled evaluations
- Basic Concepts: learn how rules, compilation, sessions, and the context stack work
- Starter Boilerplates: copy-paste service setups for Express, FastAPI, and Axum
- Operators Overview: browse all 84 built-in operators
- Troubleshooting: fix common build, bundler, and FFI loader issues
Quick Start
This page takes you from a one-shot evaluation to a reusable Engine.
The simplest path: one-shot helpers
For a one-off evaluation with default settings, call the module-level helper; you don’t build an engine first.
let result = datalogic_rs::eval_str(
r#"{">": [{"var": "score"}, 50]}"#,
r#"{"score": 75}"#,
).unwrap();
assert_eq!(result, "true");
import { apply } from '@goplasmatic/datalogic-node';
const result = apply(
{ '>': [{ var: 'score' }, 50] },
{ score: 75 }
);
console.log(result); // true
// browser/edge: same API via @goplasmatic/datalogic-wasm, see the WASM chapter
from datalogic_py import apply
result = apply(
{">": [{"var": "score"}, 50]},
{"score": 75}
)
print(result) # True
result, _ := datalogic.Apply(
`{">": [{"var": "score"}, 50]}`,
`{"score": 75}`,
)
fmt.Println(result) // "true"
import com.goplasmatic.datalogic.Engine;
try (Engine engine = new Engine()) {
String result = engine.apply(
"{\">\": [{\"var\": \"score\"}, 50]}",
"{\"score\": 75}"
);
System.out.println(result); // "true"
}
using Goplasmatic.Datalogic;
using var engine = new Engine();
var result = engine.Apply(
"""{">": [{"var": "score"}, 50]}""",
"""{"score": 75}"""
);
Console.WriteLine(result); // "true"
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
$result = $engine->apply(
'{">": [{"var": "score"}, 50]}',
'{"score": 75}'
);
echo $result; // "true"
The module-level helpers (eval_str in Rust, apply in Node.js, Python and Go) run on a default engine. Java, C# and PHP have no module-level function, so a default Engine and its apply method give the same one-shot. Use them for scripts, tutorials and code that needs no custom operators or configuration.
When you need an Engine
Build an Engine when you need custom operators, a non-default configuration, templating mode, or a long-lived Session that reuses memory in a hot loop.
use datalogic_rs::Engine;
// 1. Create an engine
let engine = Engine::new();
// 2. Compile a rule once (returns reusable compiled Logic)
let compiled = engine.compile(r#"{">": [{"var": "score"}, 50]}"#).unwrap();
// 3. Evaluate against data via a Session (reuses memory buffer)
let mut session = engine.session();
let result = session.eval_str(&compiled, r#"{"score": 75}"#).unwrap();
assert_eq!(result, "true");
session.reset(); // Reset between evaluations to keep memory flat
import { Engine } from '@goplasmatic/datalogic-node';
// 1. Create an engine
const engine = new Engine();
// 2. Compile once (returns a reusable Rule)
const rule = engine.compile({ '>': [{ var: 'score' }, 50] });
// 3. Evaluate via a session (reuses the arena across calls)
const sess = engine.session();
const result = sess.evaluate(rule, { score: 75 });
console.log(result); // true
// browser/edge: same API via @goplasmatic/datalogic-wasm, see the WASM chapter
from datalogic_py import Engine
# 1. Create an engine
engine = Engine()
# 2. Compile once
rule = engine.compile({">": [{"var": "score"}, 50]})
# 3. Evaluate
result = rule.evaluate({"score": 75})
print(result) # True
// 1. Create engine (defer close to prevent FFI leak)
engine := datalogic.NewEngine()
defer engine.Close()
// 2. Compile once (defer close to prevent FFI leak)
rule, _ := engine.Compile(`{">": [{"var": "score"}, 50]}`)
defer rule.Close()
// 3. Open session for evaluation (defer close to prevent FFI leak)
session := engine.Session()
defer session.Close()
result, _ := session.Evaluate(rule, `{"score": 75}`)
fmt.Println(result) // "true"
import com.goplasmatic.datalogic.Engine;
import com.goplasmatic.datalogic.Rule;
import com.goplasmatic.datalogic.Session;
// 1. Create an engine, 2. compile once, 3. evaluate via a session;
// try-with-resources frees the native handles
try (Engine engine = new Engine();
Rule rule = engine.compile("{\">\": [{\"var\": \"score\"}, 50]}");
Session session = engine.openSession()) {
String result = session.evaluate(rule, "{\"score\": 75}");
System.out.println(result); // "true"
}
using Goplasmatic.Datalogic;
// 1. Create an engine
using var engine = new Engine();
// 2. Compile once
using var rule = engine.Compile("""{">": [{"var": "score"}, 50]}""");
// 3. Evaluate via a session (arena reuse across calls)
using var session = engine.OpenSession();
var result = session.Evaluate(rule, """{"score": 75}""");
Console.WriteLine(result); // "true"
use Goplasmatic\Datalogic\Engine;
// 1. Create an engine
$engine = new Engine();
// 2. Compile once
$rule = $engine->compile('{">": [{"var": "score"}, 50]}');
// 3. Evaluate via a session (arena reuse across calls)
$session = $engine->openSession();
$result = $session->evaluate($rule, '{"score": 75}');
echo $result; // "true"
The Rust chapter and each language’s chapter cover engine configuration and sessions in depth.
Working with Variables
Access data using the var operator:
// Simple variable access
{ "var": "name" }
// Data: { "name": "Alice" }
// Result: "Alice"
// Nested access with dot notation
{ "var": "user.address.city" }
// Data: { "user": { "address": { "city": "New York" } } }
// Result: "New York"
// Default value for missing keys
{ "var": ["missing_key", "default_value"] }
// Data: {}
// Result: "default_value"
Try it:
Conditional Logic
Use if for branching:
{ "if": [{ ">=": [{ "var": "age" }, 18] }, "adult", "minor"] }
// Data: { "age": 25 }
// Result: "adult"
// Data: { "age": 15 }
// Result: "minor"
Try it:
Combining Conditions
Use and and or to combine conditions:
// AND: all conditions must be true
{ "and": [
{ ">=": [{ "var": "age" }, 18] },
{ "==": [{ "var": "verified" }, true] }
] }
// Data: { "age": 21, "verified": true }
// Result: true
// OR: at least one condition must be true
{ "or": [
{ "==": [{ "var": "role" }, "admin"] },
{ "==": [{ "var": "role" }, "moderator"] }
] }
// Data: { "role": "admin" }
// Result: true
Try it:
Array Operations
Filter, map, and reduce arrays:
// filter: keep elements matching a condition ("" is the current element)
{ "filter": [{ "var": "numbers" }, { ">": [{ "var": "" }, 5] }] }
// Data: { "numbers": [1, 3, 5, 7, 9] }
// Result: [7, 9]
// map: transform each element
{ "map": [{ "var": "numbers" }, { "*": [{ "var": "" }, 2] }] }
// Data: { "numbers": [1, 2, 3] }
// Result: [2, 4, 6]
Try it:
Error Handling
A failed evaluation is a structured error: a stable type tag, a message, the innermost operator that failed, and a breadcrumb of compiled-node ids leading to it:
{ "+": ["text", 1] }
// Data: {}
// Error: { "type": "Thrown", "message": "Thrown: {\"type\":\"NaN\"}", "thrown": { "type": "NaN" }, "operator": "+", "node_ids": [3] }
The tag here is Thrown; its thrown payload is the JSONLogic-style {"type": "NaN"} value that arithmetic raises on a non-numeric string.
To recover from a runtime error inside the rule, wrap the expression in try (in the Rust crate, enable the error-handling feature; every language binding has it):
{ "try": [{ "/": [10, { "var": "divisor" }] }, 0] }
// Data: { "divisor": 0 }
// Result: 0 (the division throws, so the fallback is returned)
Try it:
Each binding’s chapter covers how an uncaught error reaches your code (Rust Result, JavaScript and Python exceptions, Go error values, Java, C# and PHP exceptions): Node.js, browser WASM, Python, Go, Java, .NET, PHP.
compile accepts an unknown operator and leaves it to fail when evaluation reaches it. To catch such mistakes first, run check on the rule (Engine::check in Rust): it lists unknown operators with a “did you mean” suggestion and wrong argument counts, each located by a JSON Pointer (Rule Analysis). To make a misspelled data path fail instead of reading as null, set MissingVar::Error ("missing_var": "error" in a binding’s config; see Configuration).
Next Steps
- Basic Concepts: how rules, compilation, sessions, and evaluation fit together
- Starter Boilerplates: Express, FastAPI, and Axum service templates
- Operators: every built-in operator with runnable examples
- Use Cases & Examples: complete rule patterns for real workloads
- Language chapters: Rust, Node.js, JavaScript in the browser (WASM), Python (Quick Start), Go (Quick Start), Java / Kotlin, .NET, PHP
Basic Concepts
This page covers the model behind datalogic-rs: rules, compilation, evaluation and the engine.
JSONLogic Format
A JSONLogic rule is a JSON object where:
- The key is the operator name
- The value is an array of arguments (or a single argument)
{ "operator": [arg1, arg2, ...] }
Arguments can be:
- Literal values:
1,"hello",true,null - Arrays:
[1, 2, 3] - Nested operations:
{ "var": "x" }
Examples
// Simple comparison
{ ">": [5, 3] } // true
// Variable access
{ "var": "user.name" } // Access user.name from data
// Nested operations
{ "+": [{ "var": "a" }, { "var": "b" }] } // Add two variables
// Multiple arguments
{ "and": [true, true, false] } // false
Compilation vs Evaluation
datalogic-rs works in two phases: you compile a rule once, then evaluate it against as many data payloads as you need.
Compilation Phase
When you compile a rule, the engine parses the JSON, resolves operator names to OpCodes (and custom operators to their slot on the engine), runs constant folding, strength reduction, dead-branch removal and common-subexpression elimination, and returns an immutable compiled tree you can reuse:
// Compiles to a reusable Logic AST
let compiled = engine.compile(r#"{">": [{"var": "x"}, 10]}"#).unwrap();
// Logic is Send + Sync; wrap in Arc for cross-thread sharing
let shared = std::sync::Arc::new(compiled);
// Compiles to a reusable Rule handle
const rule = engine.compile({ '>': [{ var: 'x' }, 10] });
// browser/edge: same API via @goplasmatic/datalogic-wasm, see the WASM chapter
# Compiles to a reusable Rule object
rule = engine.compile({">": [{"var": "x"}, 10]})
// Compiles to a reusable *Rule
rule, _ := engine.Compile(`{">": [{"var": "x"}, 10]}`)
defer rule.Close()
// Compiles to a reusable Rule (AutoCloseable; thread-safe, share freely)
Rule rule = engine.compile("{\">\": [{\"var\": \"x\"}, 10]}");
// Compiles to a reusable Rule (IDisposable; thread-safe, share freely)
using var rule = engine.Compile("""{">": [{"var": "x"}, 10]}""");
// Compiles to a reusable Rule object
$rule = $engine->compile('{">": [{"var": "x"}, 10]}');
compile accepts a rule with an unknown operator and leaves it to fail when evaluation reaches it. check reports that and other mistakes before anything runs, and compile_checked (compileChecked in the bindings) refuses such a rule; see Rule Analysis.
Evaluation Phase
During evaluation, the engine dispatches each node by its OpCode and reads from the data context. Intermediate values live in a memory arena: a fresh one per one-shot call, or the one a session owns.
To evaluate a compiled rule against data with a reusable session:
let engine = Engine::new();
let compiled = engine.compile(r#"{">": [{"var": "x"}, 10]}"#).unwrap();
// Reusable session: reuses the memory buffer across calls.
let mut session = engine.session();
let result = session.eval_str(&compiled, r#"{"x": 42}"#).unwrap();
assert_eq!(result, "true");
session.reset(); // Reset between batches
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine();
const rule = engine.compile({ '>': [{ var: 'x' }, 10] });
// Session reuses one arena across calls
const sess = engine.session();
const result = sess.evaluate(rule, { x: 42 });
console.log(result); // true
// browser/edge: same API via @goplasmatic/datalogic-wasm, see the WASM chapter
from datalogic_py import Engine
engine = Engine()
rule = engine.compile({">": [{"var": "x"}, 10]})
# Direct evaluation against python dictionaries
result = rule.evaluate({"x": 42})
print(result) # True
engine := datalogic.NewEngine()
defer engine.Close()
rule, _ := engine.Compile(`{">": [{"var": "x"}, 10]}`)
defer rule.Close()
session := engine.Session()
defer session.Close()
result, _ := session.Evaluate(rule, `{"x": 42}`)
fmt.Println(result) // "true"
// try-with-resources frees the native handles
try (Engine engine = new Engine();
Rule rule = engine.compile("{\">\": [{\"var\": \"x\"}, 10]}");
Session session = engine.openSession()) {
String result = session.evaluate(rule, "{\"x\": 42}");
System.out.println(result); // "true"
}
using var engine = new Engine();
using var rule = engine.Compile("""{">": [{"var": "x"}, 10]}""");
// Session reuses one arena across calls
using var session = engine.OpenSession();
var result = session.Evaluate(rule, """{"x": 42}""");
Console.WriteLine(result); // "true"
$engine = new Engine();
$rule = $engine->compile('{">": [{"var": "x"}, 10]}');
// Session reuses one arena across calls
$session = $engine->openSession();
$result = $session->evaluate($rule, '{"x": 42}');
echo $result; // "true"
The Engine
The Engine holds the evaluation configuration and the registered custom operators.
To build and configure an engine in each runtime:
use datalogic_rs::{Engine, EvaluationConfig};
// 1. Default engine
let engine = Engine::new();
// 2. Engine with custom configurations
let engine = Engine::builder()
.with_config(EvaluationConfig::strict())
.build();
// 3. Engine with custom operators
let engine = Engine::builder()
.add_operator("double", DoubleOperator)
.build();
import { Engine } from '@goplasmatic/datalogic-node';
// 1. Default engine
const engine = new Engine();
// 2. Engine with custom operators
const engineWithOps = new Engine({}, {
double: (argsJson) => {
const args = JSON.parse(argsJson);
return JSON.stringify(args[0] * 2);
}
});
import json
from datalogic_py import Engine
# 1. Default engine
engine = Engine()
# 2. Configured engine with custom operators
engine_with_ops = Engine(
templating=True, # Enable JSON templating mode
custom_operators={
"double": lambda args_json: json.dumps(json.loads(args_json)[0] * 2)
}
)
import datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
// 1. Default engine
engine := datalogic.NewEngine()
defer engine.Close()
// 2. Engine with custom operators via a fluent builder
engineWithOps, err := datalogic.NewEngineBuilder().
AddOperator("double", func(argsJson string) (string, error) {
// implementation
return "result", nil
}).
Build()
if err != nil {
panic(err)
}
defer engineWithOps.Close()
import com.goplasmatic.datalogic.Engine;
// 1. Default engine (AutoCloseable; close it when done)
Engine engine = new Engine();
// 2. Engine with custom operators via the builder
// (argsJson is a JSON array string; parse with your JSON library, Jackson shown)
Engine engineWithOps = Engine.builder()
.addOperator("double", argsJson -> {
int n = mapper.readTree(argsJson).get(0).asInt();
return String.valueOf(n * 2);
})
.build();
using Goplasmatic.Datalogic;
// 1. Default engine
using var engine = new Engine();
// 2. Engine with custom operators via the builder
using var engineWithOps = Engine.Builder()
.AddOperator("double", argsJson =>
{
var n = System.Text.Json.Nodes.JsonNode.Parse(argsJson)![0]!.GetValue<double>();
return (n * 2).ToString();
})
.Build();
use Goplasmatic\Datalogic\Engine;
// 1. Default engine
$engine = new Engine();
// 2. Engine with custom operators via the builder
$engineWithOps = Engine::builder()
->addOperator('double', function (string $argsJson): string {
$args = json_decode($argsJson, true);
return (string) ((int) $args[0] * 2);
})
->build();
The engine:
- owns the registered custom operators, fixed at
build() - holds the evaluation configuration
- provides the compile and evaluate methods
Operator registration is builder-only. To change a running engine’s
operators or configuration, start a builder from it with
Engine::to_builder() and build a new engine; the two engines share
each operator instance.
Context Stack
The context stack tracks variable scope during evaluation. Iterator
operators such as map, filter and reduce push a frame for each
element.
// In a filter operation, "" refers to the current element
let r = datalogic_rs::eval_str(
r#"{"filter": [[1, 2, 3, 4, 5], {">": [{"var": ""}, 3]}]}"#,
r#"{}"#,
).unwrap();
// Result: "[4,5]"
Inside an iterator body:
{"var": ""}reads the current elementvalwith a level marker ({"val": [[2], "field"]}) reads an enclosing frame or the root (see Variable Access)- each nested iterator pushes its own frame and pops it when done
Type Coercion
Several operators coerce types:
Arithmetic
- Arithmetic operators parse numeric strings as numbers (
"5" + 3 = 8) - A non-numeric string, including
"NaN","inf"and"infinity", raises aThrown { type: "NaN" }error by default; change that withEvaluationConfig::arithmetic_nan_handling - Integers stay exact up to the
i64range: values above 2^53 compare, divide and sort without rounding
Comparison
==performs loose equality (with type coercion)===performs strict equality (no coercion)
Truthiness
By default, the engine uses JavaScript-style truthiness:
- Falsy:
false,0,NaN,"",null,[],{} - Truthy: everything else
EvaluationConfig::truthy_evaluator changes the rule, and Engine::truthy_of
(truthy in the bindings) applies the engine’s rule to a value you
already hold.
Thread Safety
Logic is Send + Sync, so you can share it across threads in an Arc:
use datalogic_rs::Engine;
use std::sync::Arc;
use std::thread;
let engine = Arc::new(Engine::new());
let compiled = engine.compile_arc(r#"{"+": [{"var": "x"}, 1]}"#).unwrap();
let handles: Vec<_> = (0..4).map(|i| {
let engine = Arc::clone(&engine);
let compiled = Arc::clone(&compiled);
thread::spawn(move || {
let mut session = engine.session();
session.eval_str(&compiled, &format!(r#"{{"x": {}}}"#, i)).unwrap()
})
}).collect();
for h in handles {
println!("{}", h.join().unwrap());
}
A Session borrows its engine. To keep a session in a struct field, move
it to another thread, or hold it across an .await, use SharedSession,
which holds the engine by Arc (SharedSession::new(Arc<Engine>)); see
Thread Safety.
Next Steps
- Starter Boilerplates: put compilation and sessions to work in Express, FastAPI, and Axum
- Operators Overview: browse every available operator
- Configuration: customize evaluation behavior, NaN handling, and operator families
- Rule Analysis: validate rules with
checkand inspect data reads withfacts - Custom Operators: extend the engine with your own host-language logic
- Migration Guide: upgrade from v4 to v5, or from an earlier 5.x release
Starter Boilerplates
Minimal, production-shaped starting points for three web frameworks: route authorization in Express (Node.js), a pricing endpoint in FastAPI (Python), and a feature-flag endpoint in Axum (Rust). Each example compiles its rules once at startup and evaluates them per request.
For full production walkthroughs—including database-backed rule caches, static validation on save, and request-scoped sessions—see Integration: Express and Integration: Spring Boot.
Node.js + Express
Authorize routes with @goplasmatic/datalogic-node middleware. The middleware compiles one rule per route and evaluates it against request properties (here, a role header).
Middleware Implementation
import express from 'express';
import { Engine } from '@goplasmatic/datalogic-node';
const app = express();
const engine = new Engine();
// Authorization rules, as you might load them from a database
const rules = {
"/admin": { "==": [{ "var": "user.role" }, "admin"] },
"/billing": { "in": [{ "var": "user.role" }, ["admin", "billing_manager"]] }
};
// Compile each rule once at startup, keyed by route
const compiledRules = {};
for (const [route, rule] of Object.entries(rules)) {
compiledRules[route] = engine.compile(rule);
}
// Authorization middleware
const authorize = (req, res, next) => {
const routeRule = compiledRules[req.path];
if (!routeRule) return next(); // No rule for this route
// Request context the rule reads
const context = {
user: {
role: req.headers['x-user-role'] || 'guest'
}
};
try {
const isAllowed = routeRule.evaluate(context); // a JS value: true / false
if (isAllowed) {
next();
} else {
res.status(403).json({ error: 'Forbidden' });
}
} catch (err) {
res.status(500).json({ error: 'Auth evaluation error' });
}
};
app.use(authorize);
app.get('/admin', (req, res) => res.send('Welcome, Admin!'));
app.get('/billing', (req, res) => res.send('Billing dashboard'));
Python + FastAPI
Compute a discount at the API boundary with datalogic-py. The same shape works for tax or shipping rules.
Pricing Endpoint
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datalogic_py import Engine, DataLogicError
app = FastAPI()
engine = Engine()
# Rule: If cart value > 100 AND user is VIP, discount = 20%; otherwise 5%
discount_rule = engine.compile({
"if": [
{
"and": [
{">": [{"var": "cart_total"}, 100]},
{"==": [{"var": "user.is_vip"}, True]}
]
},
0.20,
0.05
]
})
class CartContext(BaseModel):
cart_total: float
user: dict # e.g. {"name": "Alice", "is_vip": True}
@app.post("/calculate-discount")
async def get_discount(context: CartContext):
try:
# Evaluate against request data
discount_percentage = discount_rule.evaluate(context.model_dump())
return {"discount_percentage": discount_percentage}
except DataLogicError as e:
raise HTTPException(status_code=400, detail=f"Rule evaluation failed: {str(e)}")
Rust + Axum
A feature-flag endpoint that compiles its rule at startup and opens a session per request. session.eval_into::<T, _>(...) needs the serde_json feature:
datalogic-rs = { version = "5", features = ["serde_json"] }.
use axum::{routing::post, Json, Router};
use datalogic_rs::{Engine, Logic};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
struct AppState {
engine: Engine,
rule: Logic,
}
#[derive(Deserialize, Serialize)]
struct UserContext {
user_id: String,
country: String,
beta_user: bool,
}
#[derive(Serialize)]
struct FlagResponse {
enabled: bool,
}
async fn check_flag(
axum::extract::State(state): axum::extract::State<Arc<AppState>>,
Json(payload): Json<UserContext>,
) -> Json<FlagResponse> {
// A session owns the arena for this request's evaluation
let mut session = state.engine.session();
// An evaluation error counts as "flag off"
let result = session.eval_into::<bool, _>(
&state.rule,
&serde_json::to_value(payload).unwrap()
).unwrap_or(false);
Json(FlagResponse { enabled: result })
}
#[tokio::main]
async fn main() {
let engine = Engine::new();
// Rule: enable the beta feature for beta users or users in CA
let rule = engine.compile(r#"{
"or": [
{"==": [{"var": "beta_user"}, true]},
{"==": [{"var": "country"}, "CA"]}
]
}"#).unwrap();
let state = Arc::new(AppState { engine, rule });
let app = Router::new()
.route("/flag", post(check_flag))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Next Steps
- Full service integrations: Integration: Express and Integration: Spring Boot
- Language guides: Node.js, Python, Rust, Go, Java / Kotlin, .NET, PHP
- Production hardening: Thread Safety, Rule Analysis (
check&facts), Operation Budget, and Security & Sandboxing
Operators Overview
datalogic-rs provides 84 built-in operators organized into logical categories. In the Rust crate, 33 baseline operators are always available in the default build (default = []); a further 29 canonical operators are enabled by opt-in Cargo features, twenty tensor-marshalling operators sit behind the tensor feature, and two flagd-compatible operators (fractional, sem_ver) sit behind the flagd feature. Every language binding (WASM, Node, Python, Go, JVM, .NET, PHP) ships with all operator features enabled, so outside Rust the full set is available with no configuration. Counts are by canonical operator: var and ?: are accepted as input aliases of val and if, and match is an alias of switch, so the aliases are not counted separately. Each page in this section gives an operator’s syntax, argument counts, examples and edge cases.
Operator Categories
| Category | Operators | Description |
|---|---|---|
| Variable Access | val (alias var), exists | Access and check data |
| Comparison | ==, ===, !=, !==, >, >=, <, <= | Compare values |
| Logical | !, !!, and, or | Boolean logic |
| Arithmetic | +, -, *, /, %, max, min, abs, ceil, floor | Math operations |
| Control Flow | if (alias ?:), ??, switch (alias match), type | Conditional branching |
| String | cat, substr, in, length, starts_with, ends_with, upper, lower, trim, split | String manipulation |
| Array | merge, filter, map, reduce, all, some, none, sort, slice, group_by, distinct | Array operations |
| Object | keys, values, entries | Object take-apart |
| DateTime | datetime, timestamp, parse_date, format_date, date_diff, now | Date and time |
| Missing Values | missing, missing_some | Check for missing data |
| Error Handling | try, throw | Exception handling |
| Tensor | tensor, zeros, full, scatter, rle_expand, one_hot, stack, concat, unstack, reshape, transpose, pad, crop, cast, normalize, argmax, gather, to_list, shape, dtype | Marshalling JSON to and from typed n-dimensional buffers; requires features = ["tensor"] |
| flagd-Compat | fractional, sem_ver | Feature-flag targeting (OpenFeature flagd spec); requires features = ["flagd"] |
Which operators need which Cargo feature
The split applies to the Rust crate. The default build (default = []) compiles in the baseline only, and the all-operators feature turns on every family below at once. A rule that names an operator your build left out still compiles: Engine::compile treats the unknown key like an unregistered custom operator, and evaluating the rule fails with an InvalidOperator error naming the operator. Engine::check and Engine::compile_checked report the unknown operator before the rule runs. In templating mode the engine echoes an unknown key as data instead (see Structured Objects). Every language binding enables all operator features, so the full set is available there.
| Cargo feature | Operators |
|---|---|
| baseline (always on) | val/var, ==, ===, !=, !==, >, >=, <, <=, !, !!, and, or, if/?:, +, -, *, /, %, max, min, cat, substr, in, merge, filter, map, reduce, all, some, none, missing, missing_some |
datetime | datetime, timestamp, parse_date, format_date, date_diff, now |
ext-string | length, starts_with, ends_with, upper, lower, trim, split |
ext-array | sort, slice, group_by, distinct |
ext-object | keys, values, entries |
ext-control | exists, ??, switch/match, type |
error-handling | try, throw |
ext-math | abs, ceil, floor |
tensor | tensor, zeros, full, scatter, rle_expand, one_hot, stack, concat, unstack, reshape, transpose, pad, crop, cast, normalize, argmax, gather, to_list, shape, dtype |
flagd | fractional, sem_ver |
tensor-half adds no operators; it lets the element-wise tensor operators handle f16 / bf16.
The table above is generated from the engine’s operator table
(UPDATE_OPERATORS_JSON=1 cargo test -p datalogic-rs --all-features --test operators_json_test regenerates it, together with
operators.json).
Operator families
Each row of the table is one operator family: Core (the baseline),
DateTime, ExtString, ExtArray, ExtObject, ExtControl,
ErrorHandling, ExtMath, Tensor and Flagd, in table order. Since
5.8.0 you can also keep one engine to a subset of the families its build
compiled in:
use datalogic_rs::{Engine, Family};
// The JSONLogic core plus the string extensions.
let engine = Engine::builder().with_families([Family::ExtString]).build();
On that engine a family you left out behaves like one the build never
compiled, and Core is always present. The bindings take the same option
as a list of family names (families, Families, withFamilies or
WithFamilies). See
Operator Families.
Operator catalogue
Engine::operators() describes every built-in operator an engine has:
canonical name and aliases, family and Cargo feature, the argument counts
it reads (min_args, and max_args or no upper limit), whether it reads
the data context, its effect (pure, clock, throws, catches), its
cost class, and which argument runs under a pushed frame.
operators.json is that catalogue for an
all-operators build, and the bindings return the same JSON from their
operators call.
Engine::builtin_operator_names()
lists the names alone, aliases included. Both follow the engine’s
families, so use them instead of a hand-copied list when tooling needs to
know what an engine evaluates.
Rule Analysis
covers the catalogue API and its fields.
An argument past max_args is never evaluated. Most operators ignore it;
length, the tensor operators and the JSONLogic iterators (filter,
map, reduce, all, some, none) reject it, and sem_ver returns
null.
Operator Syntax
All operators follow the JSONLogic format:
{ "operator": [arg1, arg2, ...] }
Some operators accept a single argument without an array:
{ "var": "name" }
// Equivalent to:
{ "var": ["name"] }
Lazy Evaluation
These operators evaluate an argument only when they need it:
and: stops at the first falsy valueor: stops at the first truthy valueif(and its alias?:): evaluates only the matching branch??: stops at the first value that is notnullswitch(and its aliasmatch): evaluates only the matching case’s result, or the defaulttry: evaluates a fallback arm only if every arm before it raised an error
This matters when a later operand could raise an error, or is expensive:
{
"and": [
{ "var": "denominator" },
{ "/": [100, { "var": "denominator" }] }
]
}
If denominator is missing or 0, and returns that falsy value and never evaluates the division (which would throw a NaN error for an integer zero divisor); with { "denominator": 4 } the result is 25. Under the default configuration var returns null for a missing path, so plain property access such as { "var": "user.profile.name" } needs no guard; an engine configured with MissingVar::Error raises VariableNotFound for it instead (see Missing Variables).
Type Coercion
Loose vs Strict
==and!=perform type coercion===and!==require exact type match
{ "==": [1, "1"] } // true (loose)
{ "===": [1, "1"] } // false (strict)
Numeric Coercion
Arithmetic operators attempt to convert values to numbers:
{ "+": ["5", 3] } // 8 (string "5" becomes number 5)
Truthiness
Boolean operators use configurable truthiness rules (see
Truthiness Evaluation). Under the default,
TruthyEvaluator::JavaScript:
- Falsy:
false,0,"",null,[],{} - Truthy: everything else
Custom Operators
You register your own operators on the engine builder. See Custom Operators for details.
let engine = Engine::builder()
.add_operator("myop", MyOperator)
.build();
Custom operators follow the same syntax in rules:
{ "myop": [arg1, arg2] }
A custom operator registered under the name of a built-in the engine has
never runs, because the built-in wins at compile time.
EngineBuilder::try_add_operator refuses such a name with a
ConfigurationError.
There is no preserve operator. To emit a JSON object verbatim from a
rule, use templating mode (Engine::builder().with_templating(true).build(),
Cargo feature templating). Literal scalars and arrays work inline in
every mode.
Emitting a key that is an operator name. Because a single-key object is an operator invocation,
{"type": {"var": "x"}}runs thetypeoperator rather than emitting atypefield, and the same applies to every name on this page plus any custom operator you register. In templating mode you can opt into an escape prefix,Engine::builder().with_template_key_escape('$'), and write{"$type": ...}to emittype({"$$type": ...}emits a literal$type). See Structured Objects.
Variable Access Operators
These operators access data from the evaluation context.
Feature flags (Rust crate).
varandvalare baseline;existsrequires theext-controlfeature. Every language binding enables all operator features. An engine built withEngineBuilder::with_familieshasexistsonly if it namesFamily::ExtControl. See the feature table.
var
Access a value from the data object using dot notation.
Syntax:
{ "var": "path" }
{ "var": ["path", default] }
Arguments:
path- Dot-separated path to the value (a string, a number, or an expression that produces one)default- Optional value returned when the path does not exist
Returns: The value at the path, or the default value, or null.
Examples:
// Simple access
{ "var": "name" }
// Data: { "name": "Alice" }
// Result: "Alice"
// Nested access
{ "var": "user.address.city" }
// Data: { "user": { "address": { "city": "NYC" } } }
// Result: "NYC"
// Array index access
{ "var": "items.0" }
// Data: { "items": ["a", "b", "c"] }
// Result: "a"
// Default value
{ "var": ["missing", "default"] }
// Data: {}
// Result: "default"
// Access entire data object
{ "var": "" }
// Data: { "x": 1, "y": 2 }
// Result: { "x": 1, "y": 2 }
Try it:
Notes:
- Empty string
""returns the entire data context - In array operations (
map,filter,reduce),""refers to the current element - Numeric segments index arrays (
items.0); strings are not indexable, so{ "var": "s.1" }on{ "s": "hello" }isnull. Usesubstrto read characters - A literal path always splits on dots, so
{ "var": "a.b" }cannot read a key named"a.b"; use{ "val": "a.b" }for that. A path computed at runtime ({ "var": { "cat": ["a", ".b"] } }) is looked up as a key first and walked as a dotted path only if no such key exists - The default applies only when the path is absent: a present
nullstaysnull - Returns
nullif the path doesn’t exist and no default is provided, or raises an error underMissingVar::Error(see Missing paths)
val
Alternative variable access with explicit path segments and scope levels.
Syntax:
{ "val": "key" }
{ "val": ["segment1", "segment2", ...] }
{ "val": [[N], "segment1", ...] }
{ "val": [] }
Arguments:
key- A single literal key (string)["segment1", "segment2", ...]- Path segments walked in order. Every array element is one segment (a string key or a numeric array index); there is no default-value slot[[N], ...]- An optional leading scope level for iteration frames (see Scope levels below)[]- No segments: returns the current context (the whole data object, or the current element inside an iterator)
Returns: The value at the path, or null if any segment is missing. val has no default argument: use var’s second argument or ?? for a fallback.
Examples:
// Simple access (same as var)
{ "val": "name" }
// Data: { "name": "Bob" }
// Result: "Bob"
// Nested access (use the array form; a dot string is NOT split)
{ "val": ["config", "settings", "enabled"] }
// Data: { "config": { "settings": { "enabled": true } } }
// Result: true
// A dot-path string is treated as a single literal key, so it does NOT navigate
{ "val": "config.settings.enabled" }
// Data: { "config": { "settings": { "enabled": true } } }
// Result: null (looks up the key "config.settings.enabled", which is absent)
// Numeric segments index arrays
{ "val": ["items", 0] }
// Data: { "items": ["a", "b"] }
// Result: "a"
// No default slot: a second element is another path segment
{ "val": ["missing", "default"] }
// Data: {}
// Result: null (looks up data.missing.default)
// Fallbacks use ?? (or var's default form) instead
{ "??": [{ "val": ["missing", "key"] }, "default"] }
// Data: {}
// Result: "default"
Try it:
Notes:
- A literal string argument is a single key, so
{ "val": "a.b" }looks up the key"a.b"; it does not descend intoathenb - For nested access use the array form
{ "val": ["a", "b"] }, where each element is one path segment - There is no
[path, default]form:{ "val": ["a", "b"] }always walksathenb. Use{ "var": ["a", "default"] }or{ "??": [{ "val": "a" }, "default"] }for a fallback. UnderMissingVar::Errorthe??form raises; see Missing paths - A string computed at runtime is read the way
varreads a computed path:{ "val": { "cat": ["a", ".b"] } }reads the key"a.b"if present, otherwiseathenb. In the array form, a computed segment containing dots is walked as a dotted path Logic::to_json()writes a dottedvalkey back as{ "val": "a.b" }, so the serialized rule reads the same key
Scope levels
Inside an iterator (map, filter, all, some, none, group_by, and
the per-element expressions of sort, distinct, and reduce) the current
element is the data context, so { "val": "field" } reads the element. A
leading [N] array selects a different frame. This is the only way to reach
an enclosing iterator’s element, or iteration metadata (the element’s index
or key).
Syntax:
{ "val": [] }
{ "val": [[1], "index"] }
{ "val": [[1], "key"] }
{ "val": [[N]] }
{ "val": [[N], "segment1", "segment2", ...] }
Levels come in pairs, because the reference implementation this form comes
from keeps two entries per iterator: the iteration metadata, then the
enclosing element. [[1]] and [[2]] therefore name the same frame, one
iterator out; [[3]] and [[4]] name the one beyond it, and so on. Levels
count frames, not iterators of a particular kind: a reduce body and a
try catch arm each consume a level exactly like a map body.
{ "val": [] }returns the current element itself (same as{ "var": "" }).{ "val": [[N]] }and{ "val": [[N], "field", ...] }read data from the frameceil(N / 2)iterators out:[[1]]/[[2]]is the enclosing element,[[3]]/[[4]]the one outside that. A level that climbs past the outermost frame resolves against the root data, so from inside a single iterator[[1], "field"]and every higher level read the root.{ "val": [[1], "index"] }returns the current element’s zero-based position while iterating an array (or an object, in stored order);{ "val": [[1], "key"] }returns its key while iterating an object.[[3]]reads the enclosing iterator’s index or key,[[5]]the one beyond it. Only odd levels work, since an even level names an element rather than its metadata.indexandkeyarenullwherever no such metadata exists:keyover an array, anything insidereduceor atrycatch arm (those frames carry no iteration metadata), and a level that climbs past the outermost frame. At an even level they are ordinary field names, so{ "val": [[2], "index"] }reads a field calledindexon the enclosing element.- The sign is ignored:
[[-2]]and[[2]]are the same level. - A marker is an array of exactly one number.
{ "val": [[0, 1]] }is a path chain walking index 0 then index 1, not level 0 with a stray tail, and{ "val": [[2, 9], "field"] }is not level 2. - Relative path syntax such as
"../field"is not supported; the engine treats it as a literal key, which resolves tonull. varaccepts the same[[N], ...]form, because it compiles toval.
Reading an enclosing element from a nested iterator:
{
"map": [
{ "val": "orders" },
{ "map": [
{ "val": "lines" },
{ "*": [{ "val": "qty" }, { "val": [[2], "rate"] }] }
]}
]
}
[[2]] leaves the inner map and reads rate from the order; [[3], "index"]
would give the order’s position, and [[4], "field"] the root data.
Differences from json-logic-engine. datalogic follows that engine for every
level it resolves, with three exceptions, each returning a value where that
engine returns null:
| case | json-logic-engine | datalogic |
|---|---|---|
| odd level with a non-metadata path | the {iterator, index} object, or null | the same frame as N + 1 |
| a level past the outermost frame | null | the root data |
{ "val": [[0], "index"] } | the element’s index field | the current index |
It also stops resolving past the fourth chain entry, where datalogic keeps climbing.
Examples:
// Pair each element with its index
{ "map": [{ "var": "items" }, { "cat": [{ "val": [[1], "index"] }, ":", { "var": "" }] }] }
// Data: { "items": ["a", "b"] }
// Result: ["0:a", "1:b"]
// Keep every element except the first
{ "filter": [[10, 20, 30], { ">": [{ "val": [[1], "index"] }, 0] }] }
// Result: [20, 30]
// Object iteration exposes the key
{ "map": [{ "var": "scores" }, { "val": [[1], "key"] }] }
// Data: { "scores": { "alice": 10, "bob": 7 } }
// Result: ["alice", "bob"]
// Compare each element against a root-level value
{ "filter": [
{ "var": "people" },
{ "==": [{ "val": "department" }, { "val": [[2], "department"] }] }
]}
// Data: {
// "department": "Engineering",
// "people": [
// { "name": "Jay", "department": "Engineering" },
// { "name": "Louisa", "department": "Sales" }
// ]
// }
// Result: [{ "name": "Jay", "department": "Engineering" }]
// Add a root value to each element
{ "map": [{ "var": "numbers" }, { "+": [{ "val": [[2], "offset"] }, { "val": [] }] }] }
// Data: { "numbers": [1, 2, 3], "offset": 10 }
// Result: [11, 12, 13]
exists
Check if a variable path exists in the data.
Syntax:
{ "exists": "key" }
{ "exists": ["key1", "key2", ...] }
{ "exists": { "var": "path" } }
Arguments:
key- A single top-level key (string), or["key1", "key2", ...]- An array of object keys walked in order for nested access, or- A
varoperation that resolves to the key/path to check
Returns: true if the path exists, false otherwise.
Examples:
// Check if key exists
{ "exists": "name" }
// Data: { "name": "Alice" }
// Result: true
// Check missing key
{ "exists": "age" }
// Data: { "name": "Alice" }
// Result: false
// Check nested path (use the array form; a dot string is one literal key)
{ "exists": ["user", "profile"] }
// Data: { "user": { "profile": { "name": "Bob" } } }
// Result: true
// A dot-path string checks a single literal key, so it does not descend
{ "exists": "user.profile" }
// Data: { "user": { "profile": { "name": "Bob" } } }
// Result: false (no top-level key named "user.profile")
// Check with var
{ "exists": { "var": "fieldName" } }
// Data: { "fieldName": "name", "name": "Alice" }
// Result: true (checks if "name" exists)
Try it:
Notes:
- Checks only whether the path exists: a key holding
nullor""exists - Segments must be object keys:
existsdoes not index arrays, so{ "exists": ["items", 0] }isfalseeven though{ "val": ["items", 0] }resolves. Test array elements with{ "!==": [{ "val": ["items", 0] }, null] }orlengthinstead - An empty segment list,
{ "exists": [] }, returnstrue
Missing paths
A var or val read whose path is absent evaluates to null, as JSONLogic
specifies. An engine whose EvaluationConfig::missing_var is
MissingVar::Error raises a VariableNotFound error naming the path
instead, so a misspelled path fails rather than producing null:
{ "var": "user.nmae" }
// Data: { "user": { "name": "Ana" } }
// Result: null (default)
// Under MissingVar::Error: VariableNotFound error naming "user.nmae"
These are not misses, and evaluate the same under both settings:
- a
varwith a default:{ "var": ["x", 0] }on{}is0 - a path that is present and holds
null { "var": "" }and{ "val": [] }, the current data- iteration metadata (
{ "val": [[1], "index"] }), which isnulloutside an iterator missing,missing_someandexists, which report absence
?? does not shield a miss, because its operand raises before ?? sees a
value. Use var’s default, or try ({ "try": [{ "var": "x" }, "fallback"] }),
which catches the error like any other. The language bindings take the
setting as "missing_var": "error" in their JSON config. See
Missing Variables.
See Also
- Missing Value Operators (
missing,missing_some): check whether required fields are present - Array Operators: iterate over arrays with
map,filter, andreduceusing scope levels - Control Flow (
??): null-coalescing fallbacks - Rule Analysis (
facts): inspect all root data paths a compiled rule reads - Operators Overview: full operator table and feature flags
Comparison Operators
Operators for comparing values. ==, === and the ordering operators take
two or more operands and stop at the first comparison that fails, leaving
later operands unevaluated. != and !== take exactly two.
Numbers compare by value, and two integers compare exactly, also above 2^53
({ "===": [9007199254740993, 9007199254740992] } is false). With the
datetime feature, two datetimes compare as instants and two durations by
length, whether written as strings or as { "datetime": ... } /
{ "timestamp": ... } values: { "==": ["2024-01-01T00:00:00Z", "2024-01-01T00:00:00+00:00"] }
is true. Only a single-key { "datetime": ... } or { "timestamp": ... }
object counts as one; a record that also has other fields is ordinary data.
Feature flags (Rust crate). All comparison operators are baseline: every build has them. See the feature table.
== (Equals)
Loose equality comparison with type coercion.
Syntax:
{ "==": [a, b] }
{ "==": [a, b, c, ...] }
Arguments:
a,b- Values to comparec, … - Optional further values; each must equala
Returns: true if every value equals the first (after type coercion), false otherwise.
Examples:
// Same type
{ "==": [1, 1] }
// Result: true
// Type coercion
{ "==": [1, "1"] }
// Result: true
{ "==": [0, false] }
// Result: true
{ "==": ["", false] }
// Result: false (a String and a Bool are compared as strings, with no JS numeric coercion)
// Null comparison
{ "==": [null, null] }
// Result: true
// Arrays
{ "==": [[1, 2], [1, 2]] }
// Result: true
// More than two operands
{ "==": [1, "1", 1.0] }
// Result: true
Try it:
Coercion rules:
| Operands | Rule |
|---|---|
| number and string | The string is read as a number: { "==": [1, "1"] } is true. An integer string matches an integer exactly, also above 2^53 |
| number and boolean | true is 1, false is 0 |
| string and boolean | The string must be "true" or "false": { "==": ["false", false] } is true, { "==": ["", false] } is false |
null and number, boolean or string | null equals 0, false and "" |
| two arrays | Equal when their elements are equal under ===: { "==": [[1], [1.0]] } is true, { "==": [[1], ["1"]] } is an error (see below) |
| two objects | false; use === to compare objects by content |
These rules differ from JavaScript’s ==: { "==": [null, 0] } is true here.
Operands that cannot be compared are an error by default (error type NaN): a number against a string that is not a finite number ({ "==": [1, "abc"] }, { "==": [1, "NaN"] }), an array or object against a scalar ({ "==": [1, [1]] }), and two arrays that differ. Set EvaluationConfig::loose_equality_errors to false to get false instead (see Loose Equality Errors). For comparison without coercion, use ===.
=== (Strict Equals)
Strict equality comparison without type coercion.
Syntax:
{ "===": [a, b] }
{ "===": [a, b, c, ...] }
Arguments:
a,b- Values to comparec, … - Optional further values; each must equala
Returns: true if every value has the same type and value as the first, false otherwise.
Examples:
// Same type and value
{ "===": [1, 1] }
// Result: true
// Different types
{ "===": [1, "1"] }
// Result: false
{ "===": [0, false] }
// Result: false
// Null
{ "===": [null, null] }
// Result: true
// An integer and a float with the same value
{ "===": [1, 1.0] }
// Result: true
Try it:
Notes:
- Arrays and objects compare by content: two objects with the same keys and values are equal
===never raises an error for mismatched types; it returnsfalse
!= (Not Equals)
Loose inequality comparison with type coercion.
Syntax:
{ "!=": [a, b] }
Arguments:
a- First valueb- Second value
Returns: true if values are not equal (after type coercion), false otherwise. The negation of ==, with the same coercion rules and errors.
Examples:
{ "!=": [1, 2] }
// Result: true
{ "!=": [1, "1"] }
// Result: false (type coercion makes them equal)
{ "!=": ["hello", "world"] }
// Result: true
Try it:
Notes (!=, !==):
- Exactly two operands are compared. Further operands are ignored and never evaluated:
{ "!=": [1, 1, 2] }isfalse - Fewer than two operands is an Invalid Arguments error
!== (Strict Not Equals)
Strict inequality comparison without type coercion.
Syntax:
{ "!==": [a, b] }
Arguments:
a- First valueb- Second value
Returns: true if values are not equal or different types, false otherwise. The negation of ===.
Examples:
{ "!==": [1, "1"] }
// Result: true (different types)
{ "!==": [1, 1] }
// Result: false
{ "!==": [1, 2] }
// Result: true
> (Greater Than)
Check if the first value is greater than the second.
Syntax:
{ ">": [a, b] }
{ ">": [a, b, c, ...] }
Arguments:
a,b- Values to comparec, … - Optional further values for chained comparison (any number)
Returns: true if a > b (and b > c if provided), false otherwise.
Examples:
// Simple comparison
{ ">": [5, 3] }
// Result: true
{ ">": [3, 5] }
// Result: false
// Chained comparison (a > b > c)
{ ">": [5, 3, 1] }
// Result: true (5 > 3 AND 3 > 1)
{ ">": [5, 3, 4] }
// Result: false (3 is not > 4)
// String comparison
{ ">": ["b", "a"] }
// Result: true (lexicographic)
// With variables
{ ">": [{ "var": "age" }, 18] }
// Data: { "age": 21 }
// Result: true
Try it:
>= (Greater Than or Equal)
Check if the first value is greater than or equal to the second.
Syntax:
{ ">=": [a, b] }
{ ">=": [a, b, c, ...] }
Arguments:
a,b- Values to comparec, … - Optional further values for chained comparison (any number)
Returns: true if a >= b (and b >= c if provided), false otherwise.
Examples:
{ ">=": [5, 5] }
// Result: true
{ ">=": [5, 3] }
// Result: true
{ ">=": [3, 5] }
// Result: false
// Chained
{ ">=": [5, 3, 3] }
// Result: true (5 >= 3 AND 3 >= 3)
< (Less Than)
Check if the first value is less than the second.
Syntax:
{ "<": [a, b] }
{ "<": [a, b, c, ...] }
Arguments:
a,b- Values to comparec, … - Optional further values for chained comparison (any number)
Returns: true if a < b (and b < c if provided), false otherwise.
Examples:
{ "<": [3, 5] }
// Result: true
{ "<": [5, 3] }
// Result: false
// Chained (useful for range checks)
{ "<": [1, 5, 10] }
// Result: true (1 < 5 AND 5 < 10)
// Range check: is x between 1 and 10?
{ "<": [1, { "var": "x" }, 10] }
// Data: { "x": 5 }
// Result: true
Try it:
<= (Less Than or Equal)
Check if the first value is less than or equal to the second.
Syntax:
{ "<=": [a, b] }
{ "<=": [a, b, c, ...] }
Arguments:
a,b- Values to comparec, … - Optional further values for chained comparison (any number)
Returns: true if a <= b (and b <= c if provided), false otherwise.
Examples:
{ "<=": [3, 5] }
// Result: true
{ "<=": [5, 5] }
// Result: true
{ "<=": [5, 3] }
// Result: false
// Range check (inclusive)
{ "<=": [1, { "var": "x" }, 10] }
// Data: { "x": 10 }
// Result: true (1 <= 10 AND 10 <= 10)
Notes (all ordering operators):
{ "<": [a, x, b] }is equivalent toa < x AND x < b, which suits range checks- The operators accept any number of operands:
{ "<": [1, 2, 3, 4] }istrueand{ "<": [1, 2, 5, 4] }isfalse. Evaluation short-circuits at the first failing pair and never evaluates later operands ({ ">": [5, 3, 4, { "throw": "boom" }] }isfalse, nothing is thrown) - Fewer than two operands (
{ "<": [1] },{ "<": [] }) is an Invalid Arguments error - Two strings compare lexicographically (
{ ">": ["10", "9"] }isfalse); a string against a number is coerced numerically ({ ">": ["10", 9] }istrue). A string that is not a finite number against a number throws aNaNerror ({ "<": ["abc", 1] },{ "<": [1, "inf"] }) nulland booleans compare as numbers ({ "<": [null, 1] }and{ "<": [false, true] }aretrue)- An array or object operand (other than a datetime or duration value) throws a
NaNerror
See Also
- Logical Operators (
!,!!,and,or): combine comparison results - Control Flow (
if,?:,switch): branch on comparisons - DateTime Operators: compare ISO 8601 instants and durations
- Configuration: Loose Equality Errors: customize how
==and!=handle incomparable types - Operators Overview: full operator table and feature flags
Logical Operators
Boolean logic operators. and and or short-circuit: they stop at the first
operand that decides the result and leave the rest unevaluated.
Feature flags (Rust crate). All logical operators are baseline: every build has them. See the feature table.
! (Not)
Logical NOT: negates the truthiness of a value.
Syntax:
{ "!": value }
{ "!": [value] }
Arguments:
value- Value to negate
Returns: true if value is falsy, false if value is truthy.
Examples:
{ "!": true }
// Result: false
{ "!": false }
// Result: true
{ "!": 0 }
// Result: true (0 is falsy)
{ "!": 1 }
// Result: false (1 is truthy)
{ "!": "" }
// Result: true (empty string is falsy)
{ "!": "hello" }
// Result: false (non-empty string is truthy)
{ "!": null }
// Result: true (null is falsy)
{ "!": [] }
// Result: true (no argument, treated as null)
// An array argument is the argument list: only the first element is negated
{ "!": [0, 2] }
// Result: true (negates 0; the 2 is ignored)
// To negate a literal array, wrap it so the array is the single argument
{ "!": [[1, 2]] }
// Result: false (non-empty array is truthy)
Try it:
Notes:
- Uses the engine’s truthiness rules (default: JavaScript-style; see Truthiness Reference)
- An array argument is read as the argument list, and
!uses only its first element. To negate a literal array, wrap it:{ "!": [[1, 2]] }
!! (Double Not / Boolean Cast)
Convert a value to its boolean equivalent.
Syntax:
{ "!!": value }
{ "!!": [value] }
Arguments:
value- Value to convert to boolean
Returns: true if value is truthy, false if value is falsy.
Examples:
{ "!!": true }
// Result: true
{ "!!": false }
// Result: false
{ "!!": 1 }
// Result: true
{ "!!": 0 }
// Result: false
{ "!!": "hello" }
// Result: true
{ "!!": "" }
// Result: false
// A literal array must be wrapped so it is the single argument
{ "!!": [[1, 2, 3]] }
// Result: true
{ "!!": [] }
// Result: false
// Unwrapped, the array is the argument list and only the first element counts
{ "!!": [0, 2, 3] }
// Result: false
{ "!!": null }
// Result: false
Try it:
Notes:
- Equivalent to
{ "!": { "!": value } }; use it to turn any value into a boolean - Like
!, an array argument is the argument list and only its first element is inspected; wrap a literal array ({ "!!": [[]] }isfalse,{ "!!": [{}] }isfalse)
and
Logical AND with short-circuit evaluation.
Syntax:
{ "and": [a, b, ...] }
Arguments:
a,b, … - One or more values to AND together, always passed as an array. A single-element array returns that element; an empty array returnsnull; a bare scalar ({ "and": 5 }) is an Invalid Arguments error
Returns: The first falsy value encountered, or the last value if all are truthy.
Examples:
// All truthy
{ "and": [true, true] }
// Result: true
// One falsy
{ "and": [true, false] }
// Result: false
// Short-circuit: returns first falsy
{ "and": [true, 0, "never evaluated"] }
// Result: 0
// All truthy returns last value
{ "and": [1, 2, 3] }
// Result: 3
// Multiple conditions
{ "and": [
{ ">": [{ "var": "age" }, 18] },
{ "==": [{ "var": "verified" }, true] },
{ "!=": [{ "var": "banned" }, true] }
]}
// Data: { "age": 21, "verified": true, "banned": false }
// Result: true
Try it:
Notes:
- Short-circuits: stops at the first falsy value
- Returns the operand itself, which need not be a boolean
or
Logical OR with short-circuit evaluation.
Syntax:
{ "or": [a, b, ...] }
Arguments:
a,b, … - One or more values to OR together, always passed as an array. A single-element array returns that element; an empty array returnsnull; a bare scalar ({ "or": "x" }) is an Invalid Arguments error
Returns: The first truthy value encountered, or the last value if all are falsy.
Examples:
// One truthy
{ "or": [false, true] }
// Result: true
// All falsy
{ "or": [false, false] }
// Result: false
// Short-circuit: returns first truthy
{ "or": [0, "", "found it", "not evaluated"] }
// Result: "found it"
// All falsy returns last value
{ "or": [false, 0, ""] }
// Result: ""
// Default value pattern
{ "or": [{ "var": "nickname" }, { "var": "name" }, "Anonymous"] }
// Data: { "name": "Alice" }
// Result: "Alice" (nickname is null/missing, so returns name)
// Role check
{ "or": [
{ "==": [{ "var": "role" }, "admin"] },
{ "==": [{ "var": "role" }, "moderator"] }
]}
// Data: { "role": "admin" }
// Result: true
Try it:
Notes:
- Short-circuits: stops at the first truthy value
- Returns the operand itself, which need not be a boolean, so
orcan supply a default value
Truthiness Reference
The default JavaScript-style truthiness:
| Value | Truthy? |
|---|---|
true | Yes |
false | No |
1, 2, -1, 3.14 | Yes |
0, 0.0 | No |
"hello", "0", "false" | Yes |
"" | No |
[1, 2], {"a": 1} | Yes |
[] | No |
{} | No |
null | No |
EvaluationConfig::truthy_evaluator replaces these rules wherever an operator tests truthiness (!, !!, and, or, if, and the conditions of filter, all, some and none); see Truthiness Evaluation. To apply the engine’s rules to a value in host code, such as an evaluated result, call Engine::truthy_of.
See Also
- Control Flow (
if,?:,??,switch): conditional branching and null-coalescing - Comparison Operators (
==,===,>,<): equality and ordering checks - Array Operators (
all,some,none): test conditions across collections - Operators Overview: full operator table and feature flags
Arithmetic Operators
Mathematical operations. +, -, *, / and % read numeric strings,
booleans and null as numbers (see Non-numeric operands);
abs, ceil and floor accept numbers and numeric strings; max and min
accept numbers only. Integer arithmetic stays exact, also above 2^53, until a
result overflows i64, where it continues as a float.
With the datetime feature, +, -, * and / also take datetimes and
durations; see Duration Arithmetic.
Feature flags (Rust crate).
+,-,*,/,%,min, andmaxare baseline;abs,ceil, andfloorrequire theext-mathfeature. Every language binding enables all operator features. An engine built withEngineBuilder::with_familieshasabs,ceilandflooronly if it namesFamily::ExtMath. See the feature table.
+ (Add)
Add numbers together (numeric strings are coerced).
Syntax:
{ "+": [a, b, ...] }
{ "+": value }
Arguments:
a,b, … - Values to add (variadic)value- A single operand is converted to a number. If it evaluates to an array (fromvar, for example),+sums the elements; a literal array written in the rule is a non-numeric operand (aNaNerror by default)
Returns: Sum of all arguments.
Examples:
// Basic addition
{ "+": [1, 2] }
// Result: 3
// Multiple values
{ "+": [1, 2, 3, 4] }
// Result: 10
// Type coercion
{ "+": ["5", 3] }
// Result: 8 (string "5" converted to number)
{ "+": ["1", "2", "3"] }
// Result: 6
// Unary plus (convert to number)
{ "+": "42" }
// Result: 42
{ "+": "-3.14" }
// Result: -3.14
// With variables
{ "+": [{ "var": "price" }, { "var": "tax" }] }
// Data: { "price": 100, "tax": 8.5 }
// Result: 108.5
// Sum an array from the data
{ "+": { "var": "items" } }
// Data: { "items": [1, 2, 3] }
// Result: 6
Try it:
Notes:
+never concatenates:{ "+": ["hello", " world"] }is aNaNerror under the defaultEvaluationConfig::arithmetic_nan_handling. Usecatto join strings{ "+": [] }returns0- An integer sum that overflows
i64continues as a float:{ "+": [9223372036854775807, 1] }is9.223372036854776e18
- (Subtract)
Subtract numbers.
Syntax:
{ "-": [a, b] }
{ "-": [a, b, c, ...] }
{ "-": value }
Arguments:
a- Value to subtract fromb,c, … - Values to subtract, folded left to right (a - b - c ...)value- A single operand is negated. If it evaluates to an array,-folds over the elements ({ "-": [[10, 3]] }is7)
Returns: Difference, or negated value. An empty argument list, or a single operand that is an empty array, is an Invalid Arguments error.
Examples:
// Subtraction
{ "-": [10, 3] }
// Result: 7
// Unary minus (negate)
{ "-": 5 }
// Result: -5
{ "-": -3 }
// Result: 3
// With coercion
{ "-": ["10", "3"] }
// Result: 7
// Variadic (left fold)
{ "-": [10, 3, 2] }
// Result: 5
// Calculate discount
{ "-": [{ "var": "price" }, { "var": "discount" }] }
// Data: { "price": 100, "discount": 15 }
// Result: 85
Try it:
* (Multiply)
Multiply numbers.
Syntax:
{ "*": [a, b, ...] }
{ "*": value }
Arguments:
a,b, … - Values to multiply (variadic)value- A single operand is converted to a number. If it evaluates to an array,*multiplies the elements; a literal array written in the rule is a non-numeric operand (aNaNerror by default)
Returns: Product of all arguments. { "*": [] } returns 1.
Examples:
// Basic multiplication
{ "*": [3, 4] }
// Result: 12
// Multiple values
{ "*": [2, 3, 4] }
// Result: 24
// With coercion
{ "*": ["5", 2] }
// Result: 10
{ "*": ["2", "3", "4"] }
// Result: 24
// Calculate total
{ "*": [{ "var": "quantity" }, { "var": "price" }] }
// Data: { "quantity": 3, "price": 25 }
// Result: 75
// Apply percentage
{ "*": [{ "var": "amount" }, 0.1] }
// Data: { "amount": 200 }
// Result: 20
Try it:
/ (Divide)
Divide numbers.
Syntax:
{ "/": [a, b] }
{ "/": [a, b, c, ...] }
{ "/": value }
Arguments:
a- Dividendb,c, … - Divisors, folded left to right (a / b / c ...)value- A single operand returns its reciprocal (1 / value). If it evaluates to an array,/folds over the elements ({ "/": [[100, 2, 5]] }is10)
Returns: Quotient, or the reciprocal for the single-value form. Integer operands divide exactly: a whole quotient stays an integer, also above 2^53 ({ "/": [18014398509481988, 2] } is 9007199254740994).
Examples:
// Basic division
{ "/": [10, 2] }
// Result: 5
// Decimal result
{ "/": [7, 2] }
// Result: 3.5
// Division by zero with two integral-valued operands throws an error (error type "NaN")
{ "/": [10, 0] }
// Result: error
// Variadic (left fold)
{ "/": [100, 2, 5] }
// Result: 10
// Unary form: reciprocal
{ "/": 5 }
// Result: 0.2
// With coercion
{ "/": ["100", "4"] }
// Result: 25
// Calculate average
{ "/": [{ "+": [10, 20, 30] }, 3] }
// Result: 20
Try it:
Notes:
- If both operands are integral-valued numbers and the divisor is zero (
{ "/": [10, 0] }, and also{ "/": [10, 0.0] }, since0.0is integral-valued), the engine throws aNaNerror under every configuration. A single zero operand ({ "/": 0 }) is aNaNerror too - Any other zero divisor follows
EvaluationConfig::division_by_zero: a non-integral dividend ({ "/": [10.5, 0] }) or a divisor coerced fromnull, a boolean, or a string ({ "/": [10, null] },{ "/": [10, "0"] }). The default,DivisionByZeroHandling::ReturnSaturated, returnsf64::MAX(f64::MINfor a negative dividend) instead ofInfinity - The other modes are
ReturnInfinity,ReturnNullandThrowError(see Division by Zero). ChooseThrowErrorif atryfallback should cover every zero-like divisor
% (Modulo)
Calculate remainder of division.
Syntax:
{ "%": [a, b] }
{ "%": [a, b, c, ...] }
Arguments:
a- Dividendb,c, … - Divisors, folded left to right ((a % b) % c ...)
Returns: Remainder after division, with the sign of the dividend. Integers stay exact above 2^53. A single operand must evaluate to an array of at least two numbers, which % folds ({ "%": [[10, 4]] } is 2); any other single operand is an Invalid Arguments error.
Examples:
// Basic modulo
{ "%": [10, 3] }
// Result: 1
{ "%": [10, 5] }
// Result: 0
// Negative numbers
{ "%": [-10, 3] }
// Result: -1
// Check if even
{ "==": [{ "%": [{ "var": "n" }, 2] }, 0] }
// Data: { "n": 4 }
// Result: true
Try it:
Notes:
- A zero divisor follows the same rule as
/: when both operands are integral-valued numbers ({ "%": [10, 0] }) the engine throws aNaNerror; otherwise ({ "%": [10.5, 0] },{ "%": [10, null] }) the result followsEvaluationConfig::division_by_zero, defaultReturnSaturated
max
Find the maximum value.
Syntax:
{ "max": [a, b, ...] }
{ "max": array }
Arguments:
a,b, … - Values to compare, orarray- A single value (such as avar) that resolves to an array. It must be a resolved array, not a literal array written inline
Returns: The largest value.
Examples:
// Multiple arguments
{ "max": [1, 5, 3] }
// Result: 5
// A single argument that resolves to an array (data-driven)
{ "max": { "var": "scores" } }
// Data: { "scores": [85, 92, 78] }
// Result: 92
// Note: a literal nested array passed positionally, e.g. { "max": [[1, 5, 3]] }
// or { "max": [[]] }, is an invalid operand and throws Invalid Arguments.
// Pass scalars directly, or a value that resolves to an array.
Try it:
Notes:
- Operands must be numbers.
maxdoes not coerce strings (even numeric ones such as"1"), booleans, ornull; they throw Invalid Arguments. This differs from json-logic-js, which coerces{ "max": ["1", 5, "3"] }to5 - An empty argument list, or a single value that resolves to an empty array, throws Invalid Arguments
min
Find the minimum value.
Syntax:
{ "min": [a, b, ...] }
{ "min": array }
Arguments:
a,b, … - Values to compare, orarray- A single value (such as avar) that resolves to an array. It must be a resolved array, not a literal array written inline
Returns: The smallest value.
Examples:
// Multiple arguments
{ "min": [5, 1, 3] }
// Result: 1
// A single argument that resolves to an array (data-driven)
{ "min": { "var": "prices" } }
// Data: { "prices": [29.99, 19.99, 39.99] }
// Result: 19.99
// Note: a literal nested array passed positionally, e.g. { "min": [[5, 1, 3]] }
// or { "min": [[]] }, is an invalid operand and throws Invalid Arguments.
// Pass scalars directly, or a value that resolves to an array.
Try it:
Notes:
- Operands must be numbers.
mindoes not coerce strings (even numeric ones), booleans, ornull; they throw Invalid Arguments - An empty argument list, or a single value that resolves to an empty array, throws Invalid Arguments
abs
Get the absolute value.
Syntax:
{ "abs": value }
{ "abs": [a, b, ...] }
Arguments:
value- Number to get absolute value of (a numeric string is coerced)a,b, … - Two or more numbers; the result is an array of per-element absolute values
Returns: Absolute (positive) value, or an array of them for the multi-argument form.
Examples:
{ "abs": -5 }
// Result: 5
{ "abs": 5 }
// Result: 5
{ "abs": -3.14 }
// Result: 3.14
{ "abs": 0 }
// Result: 0
// Distance between two points
{ "abs": { "-": [{ "var": "a" }, { "var": "b" }] } }
// Data: { "a": 3, "b": 10 }
// Result: 7
// Numeric strings are coerced
{ "abs": "-5" }
// Result: 5
// Multiple arguments: element-wise
{ "abs": [-1, 2] }
// Result: [1, 2]
Try it:
Notes (abs, ceil, floor):
- Numeric strings are coerced;
null, booleans, non-numeric strings and the strings"NaN","inf"and"infinity"throw Invalid Arguments.arithmetic_nan_handlingdoes not apply - Two or more arguments return an array of per-element results
- Unlike
max/min, a single value that resolves to an array throws Invalid Arguments:{ "abs": { "var": "a" } }with{ "a": [-1] }is an error. Usemapto apply these to a data-driven array ceilandfloorreturn an integer when the result fits ini64, and a float otherwise ({ "ceil": 1e20 }is1e20)
ceil
Round up to the nearest integer.
Syntax:
{ "ceil": value }
{ "ceil": [a, b, ...] }
Arguments:
value- Number to round up (a numeric string is coerced)a,b, … - Two or more numbers; the result is an array of per-element results
Returns: Smallest integer greater than or equal to value, or an array of them for the multi-argument form. See the notes under abs for argument rules.
Examples:
{ "ceil": 4.1 }
// Result: 5
{ "ceil": 4.9 }
// Result: 5
{ "ceil": 4.0 }
// Result: 4
{ "ceil": -4.1 }
// Result: -4
// Round up to whole units
{ "ceil": { "/": [{ "var": "items" }, 10] } }
// Data: { "items": 25 }
// Result: 3 (need 3 boxes of 10)
// Multiple arguments: element-wise
{ "ceil": [1.2, 2] }
// Result: [2, 2]
Try it:
floor
Round down to the nearest integer.
Syntax:
{ "floor": value }
{ "floor": [a, b, ...] }
Arguments:
value- Number to round down (a numeric string is coerced)a,b, … - Two or more numbers; the result is an array of per-element results
Returns: Largest integer less than or equal to value, or an array of them for the multi-argument form. See the notes under abs for argument rules.
Examples:
{ "floor": 4.9 }
// Result: 4
{ "floor": 4.1 }
// Result: 4
{ "floor": 4.0 }
// Result: 4
{ "floor": -4.1 }
// Result: -5
// Truncate decimal
{ "floor": { "var": "amount" } }
// Data: { "amount": 99.99 }
// Result: 99
// Multiple arguments: element-wise
{ "floor": [1.2, 2] }
// Result: [1, 2]
Try it:
Non-numeric operands
+, -, *, / and % read numeric strings ("5", "-3.14", "1e3"),
booleans (true is 1, false is 0), null and "" (both 0) as
numbers. EvaluationConfig::numeric_coercion turns the last three off (see
Numeric Coercion). The strings
"NaN", "inf" and "infinity", and numeric strings that overflow f64
("1e400"), are not numbers.
Any other operand, such as "abc", an array or an object, raises a NaN
error by default. EvaluationConfig::arithmetic_nan_handling changes that for
+ and *, and for the operands after the first in a - call with three or
more operands:
| Setting | { "+": [2, "x", 3] } | { "*": [2, "x", 3] } |
|---|---|---|
ThrowError (default) | NaN error | NaN error |
IgnoreValue | 5 (skips "x") | 6 (skips "x") |
CoerceToZero | 5 ("x" is 0) | 0 ("x" is 0) |
ReturnNull | null | null |
/ and % raise a NaN error for a non-numeric operand under every
setting, and so does -, except for the operands after the first in a call
with three or more. See NaN Handling.
See Also
- Configuration (
NanHandling,DivisionByZeroHandling,NumericCoercionConfig): customize arithmetic coercion and error behavior - DateTime Operators: add, subtract, and scale datetimes and durations with
+,-,*, and/ - Error Handling (
try,throw): catch arithmeticNaNand division-by-zero errors - Operators Overview: full operator table and feature flags
Control Flow Operators
Conditional branching and value selection operators.
Feature flags (Rust crate).
ifand?:are baseline;??,switch/match, andtyperequire theext-controlfeature. Every language binding enables all operator features. See the feature table.
Argument counts: if / ?: take one or more arguments, ?? any number, switch / match two or three (value, case table, default), and type one. With no arguments, if and ?? return null and type returns "null"; switch with fewer than two arguments returns null. Extra arguments to switch and type are never evaluated.
if
Conditional branching with if/then/else chains.
Syntax:
{ "if": [condition, then_value] }
{ "if": [condition, then_value, else_value] }
{ "if": [cond1, value1, cond2, value2, ..., else_value] }
Arguments:
condition- Condition to evaluatethen_value- Value if condition is truthyelse_value- Value if condition is falsy (optional)- Additional condition/value pairs for else-if chains
Returns: The value corresponding to the first truthy condition, or the else value.
Examples:
// Simple if/then
{ "if": [true, "yes"] }
// Result: "yes"
{ "if": [false, "yes"] }
// Result: null
// If/then/else
{ "if": [true, "yes", "no"] }
// Result: "yes"
{ "if": [false, "yes", "no"] }
// Result: "no"
// If/else-if/else chain
{ "if": [
{ ">=": [{ "var": "score" }, 90] }, "A",
{ ">=": [{ "var": "score" }, 80] }, "B",
{ ">=": [{ "var": "score" }, 70] }, "C",
{ ">=": [{ "var": "score" }, 60] }, "D",
"F"
]}
// Data: { "score": 85 }
// Result: "B"
// Nested if
{ "if": [
{ "var": "premium" },
{ "if": [
{ ">": [{ "var": "amount" }, 100] },
"free_shipping",
"standard_shipping"
]},
"no_shipping"
]}
// Data: { "premium": true, "amount": 150 }
// Result: "free_shipping"
Try it:
Notes:
- Only evaluates the matching branch (lazy evaluation)
- Empty condition list returns
null - Odd number of arguments uses last as else value
iftakes its arguments as an array only:{ "if": true }raises Invalid Arguments
?: (Ternary)
Ternary conditional operator (shorthand if/then/else). ?: is an input alias
of if: it compiles to the same operator, so it accepts every argument shape
if does, including the two-argument form and else-if chains.
Syntax:
{ "?:": [condition, then_value, else_value] }
{ "?:": [condition, then_value] }
{ "?:": [cond1, value1, cond2, value2, ..., else_value] }
Arguments:
condition- Condition to evaluatethen_value- Value if condition is truthyelse_value- Value if condition is falsy (optional;nullwhen omitted)- Additional condition/value pairs for else-if chains, exactly as with
if
Returns: then_value if condition is truthy, else_value otherwise (the same rules as if).
Examples:
// Basic ternary
{ "?:": [true, "yes", "no"] }
// Result: "yes"
{ "?:": [false, "yes", "no"] }
// Result: "no"
// With comparison
{ "?:": [
{ ">": [{ "var": "age" }, 18] },
"adult",
"minor"
]}
// Data: { "age": 21 }
// Result: "adult"
// Nested ternary
{ "?:": [
{ "var": "vip" },
0,
{ "?:": [
{ ">": [{ "var": "total" }, 50] },
5,
10
]}
]}
// Data: { "vip": false, "total": 75 }
// Result: 5 (shipping cost)
// Two-argument and chained forms work exactly as they do for if
{ "?:": [false, "yes"] }
// Result: null
{ "?:": [false, 1, true, 2, 3] }
// Result: 2
Try it:
Notes:
{ "?:": [c, a, b] }is exactly{ "if": [c, a, b] }, and the argument rules in theifsection apply unchanged- Only evaluates the matching branch
?? (Null Coalesce)
Return the first non-null value.
Syntax:
{ "??": [a, b] }
{ "??": [a, b, c, ...] }
Arguments:
a,b, … - Values to check (variadic)
Returns: The first non-null value, or null if all are null.
Examples:
// First is not null
{ "??": ["hello", "default"] }
// Result: "hello"
// First is null
{ "??": [null, "default"] }
// Result: "default"
// Multiple values
{ "??": [null, null, "found"] }
// Result: "found"
// All null
{ "??": [null, null] }
// Result: null
// With variables (default value pattern)
{ "??": [{ "var": "nickname" }, { "var": "name" }, "Anonymous"] }
// Data: { "name": "Alice" }
// Result: "Alice"
// Note: 0, "", and false are NOT null
{ "??": [0, "default"] }
// Result: 0
{ "??": ["", "default"] }
// Result: ""
{ "??": [false, "default"] }
// Result: false
Try it:
Notes:
- Only checks for
null, not other falsy values - Use
orif you want to skip all falsy values - Short-circuits: stops at first non-null value
switch / match
Match a value against a list of cases, returning the result of the first case
whose key strictly equals the value, or a default. match is an alias of
switch.
Syntax:
{ "switch": [value, [[case, result], ...]] }
{ "switch": [value, [[case, result], ...], default] }
Arguments:
value- The discriminant, evaluated once[[case, result], ...]- Array of[case, result]pairs; the first case that strictly equalsvalueselects its resultdefault- Optional value used when no case matches (omitted: returnsnull)
Returns: The matched case’s result, the default, or null.
Examples:
{ "switch": [
{ "var": "color" },
[["red", "stop"], ["green", "go"]],
"unknown"
]}
// Data: { "color": "green" }
// Result: "go"
// Alias `match`
{ "match": [
{ "var": "status" },
[[200, "OK"], [404, "Not Found"]],
"Unknown"
]}
// Data: { "status": 404 }
// Result: "Not Found"
// Strict matching: the number 1 does not match the string "1"
{ "switch": [1, [["1", "str"], [1, "num"]], "none"] }
// Result: "num"
// No match and no default
{ "switch": [{ "var": "x" }, [[1, "a"]]] }
// Data: { "x": 2 }
// Result: null
Notes:
- Case comparison is strict (no type coercion): the number
1does not match the string"1". switchevaluates the discriminant once and compares it against each case in order.switchevaluates only the matching case’s result (or the default).- Case keys may be expressions:
{ "switch": [{ "var": "x" }, [[{ "var": "y" }, "dyn"]], "d"] }returns"dyn"whenxequalsy. - Write the case table in the rule. A case table computed at evaluation time (
{ "switch": [{ "var": "x" }, { "var": "cases" }, "d"] }) matches nothing, so the call returns the default. switchreads at most three arguments; a fourth is never evaluated.
type
Return the runtime type of a value as a string.
Syntax:
{ "type": value }
Arguments:
value- Any value to inspect
Returns: One of "null", "boolean", "number", "string", "array", "object", "datetime", "duration", or (with the tensor feature) "tensor".
Examples:
{ "type": 42 }
// Result: "number"
{ "type": "hello" }
// Result: "string"
// A value that resolves to an array
{ "type": { "var": "items" } }
// Data: { "items": [1, 2, 3] }
// Result: "array"
{ "type": { "now": [] } }
// Result: "datetime"
// Strings are classified with the datetime and duration parsers
{ "type": "2024-01-15T10:30:00-05:00" }
// Result: "datetime"
{ "type": "2h30m" }
// Result: "duration"
{ "type": "password1" }
// Result: "string"
Notes:
typereads exactly one argument. The engine parses a literal array such as{ "type": [1, 2, 3] }as a multi-argument call, sotypeinspects the first element (here,"number"). Pass a single value that resolves to an array, such as{ "type": { "var": "items" } }, or wrap the literal:{ "type": [[1, 2, 3]] }is"array".- With the
datetimefeature,typeclassifies a string the way the comparisons decide to compare it: a string that parses as an ISO 8601 datetime, with an offset or naive, is"datetime"; one that parses as a duration is"duration"; anything else is"string". The results ofnow,datetimeandtimestampare such strings. - A single-key
{ "datetime": "..." }or{ "timestamp": "..." }object in data is"datetime"or"duration". An object with any other key beside it is"object".
Comparison: if vs ?: vs ?? vs or
| Operator | Use Case | Falsy Handling |
|---|---|---|
if | Complex branching, multiple conditions | Evaluates truthiness |
?: | Simple if/else | Evaluates truthiness |
?? | Default for null only | Only skips null |
or | Default for any falsy | Skips all falsy values |
Examples:
// Value is 0 (falsy but not null)
// Data: { "count": 0 }
{ "if": [{ "var": "count" }, { "var": "count" }, 10] }
// Result: 10 (0 is falsy)
{ "?:": [{ "var": "count" }, { "var": "count" }, 10] }
// Result: 10 (0 is falsy)
{ "??": [{ "var": "count" }, 10] }
// Result: 0 (0 is not null)
{ "or": [{ "var": "count" }, 10] }
// Result: 10 (0 is falsy)
Choose the operator based on whether you want to treat 0, "", and false as valid values.
See Also
- Logical Operators (
!,!!,and,or): short-circuit boolean logic and truthiness rules - Error Handling (
try,throw): catch runtime errors and provide fallback expressions - Variable Access (
var,val,exists): read data fields with optional defaults - Operators Overview: full operator table and feature flags
String Operators
String manipulation and searching operations.
Feature flags (Rust crate).
cat,substr, andinare baseline.length,starts_with,ends_with,upper,lower,trim, andsplitrequire theext-stringfeature. Every language binding enables all operator features, so this only affects the Rust crate. See the feature table.
Argument counts: cat takes any number of arguments; substr one to three; in, starts_with, ends_with and split two; length, upper, lower and trim one. With too few arguments, substr returns "", in returns false, and the others raise Invalid Arguments. Extra arguments are ignored, except by length, which raises Invalid Arguments.
substr, starts_with, ends_with, upper, lower, trim and split convert a non-string argument to its string form first: 12 becomes "12" and null becomes "".
cat
Concatenate strings together.
Syntax:
{ "cat": [a, b, ...] }
Arguments:
a,b, … - Values to concatenate (variadic)
Returns: Concatenated string. Numbers and booleans add their text, null adds nothing, and an array argument adds each of its items.
Examples:
// Simple concatenation
{ "cat": ["Hello", " ", "World"] }
// Result: "Hello World"
// With variables
{ "cat": ["Hello, ", { "var": "name" }, "!"] }
// Data: { "name": "Alice" }
// Result: "Hello, Alice!"
// Non-strings are converted
{ "cat": ["Value: ", 42] }
// Result: "Value: 42"
{ "cat": ["Is active: ", true] }
// Result: "Is active: true"
// Building paths
{ "cat": ["/users/", { "var": "userId" }, "/profile"] }
// Data: { "userId": 123 }
// Result: "/users/123/profile"
Try it:
substr
Extract a substring.
Syntax:
{ "substr": [string, start] }
{ "substr": [string, start, length] }
Arguments:
string- Source stringstart- Starting index (0-based, negative counts from end)length- Number of characters (optional, negative counts from end)
A start or length that is not an integer counts as absent: { "substr": ["abcdef", 1.5] } is "abcdef".
Returns: Extracted substring.
Examples:
// From start index
{ "substr": ["Hello World", 0, 5] }
// Result: "Hello"
// From middle
{ "substr": ["Hello World", 6] }
// Result: "World"
// Negative start (from end)
{ "substr": ["Hello World", -5] }
// Result: "World"
// Negative length (exclude from end)
{ "substr": ["Hello World", 0, -6] }
// Result: "Hello"
// Get file extension
{ "substr": ["document.pdf", -3] }
// Result: "pdf"
// With variables
{ "substr": [{ "var": "text" }, 0, 10] }
// Data: { "text": "This is a long string" }
// Result: "This is a "
Try it:
in
Check if a value is contained in a string or array.
Syntax:
{ "in": [needle, haystack] }
Arguments:
needle- Value to search forhaystack- String or array to search in
Returns: true if found, false otherwise.
Examples:
// String contains substring
{ "in": ["World", "Hello World"] }
// Result: true
{ "in": ["xyz", "Hello World"] }
// Result: false
// Array contains element
{ "in": [2, [1, 2, 3]] }
// Result: true
{ "in": [5, [1, 2, 3]] }
// Result: false
// Check membership
{ "in": [{ "var": "role" }, ["admin", "moderator"]] }
// Data: { "role": "admin" }
// Result: true
// Check substring
{ "in": ["@", { "var": "email" }] }
// Data: { "email": "user@example.com" }
// Result: true
Try it:
Notes:
- A string haystack requires a string needle:
{ "in": [2, "123"] }isfalse; the engine never stringifies numbers for substring search - Array membership uses strict deep equality (the same predicate as
distinct):{ "in": ["2", [1, 2, 3]] }isfalse, while{ "in": [1, [1.0, 2]] }istrueand nested arrays/objects compare structurally - Any other haystack (an object,
null, a missing variable) yieldsfalse, as does a missing haystack argument
length
Get the length of a string or array.
Syntax:
{ "length": value }
Arguments:
value- String or array
Returns: Length (number of characters or elements).
Examples:
// String length
{ "length": "Hello" }
// Result: 5
// Array length (pass a value that resolves to an array)
{ "length": { "var": "nums" } }
// Data: { "nums": [1, 2, 3, 4, 5] }
// Result: 5
// Empty string
{ "length": "" }
// Result: 0
// Empty array (data-driven)
{ "length": { "var": "empty" } }
// Data: { "empty": [] }
// Result: 0
// With variables
{ "length": { "var": "items" } }
// Data: { "items": ["a", "b", "c"] }
// Result: 3
// Check minimum length
{ ">=": [{ "length": { "var": "password" } }, 8] }
// Data: { "password": "secret123" }
// Result: true
Notes:
lengthtakes exactly one argument. The engine parses a literal array such as{ "length": [1, 2, 3] }as a multi-argument call, which throws Invalid Arguments. Pass a single value that resolves to an array (for example{ "length": { "var": "items" } }), or wrap the literal in one more array:{ "length": [[1, 2, 3]] }is3.lengthaccepts only strings and arrays:null(including a missingvar), numbers, booleans, and objects throw Invalid Arguments. Guard optional fields with??, for example{ "length": { "??": [{ "var": "items" }, []] } }, which is0whenitemsis absent.- String length counts Unicode characters, not bytes (
{ "length": "héllo" }is5).
Try it:
starts_with
Check if a string starts with a prefix.
Syntax:
{ "starts_with": [string, prefix] }
Arguments:
string- String to checkprefix- Prefix to look for
Returns: true if string starts with prefix, false otherwise.
Examples:
{ "starts_with": ["Hello World", "Hello"] }
// Result: true
{ "starts_with": ["Hello World", "World"] }
// Result: false
// Check URL scheme
{ "starts_with": [{ "var": "url" }, "https://"] }
// Data: { "url": "https://example.com" }
// Result: true
// Case sensitive
{ "starts_with": ["Hello", "hello"] }
// Result: false
Try it:
ends_with
Check if a string ends with a suffix.
Syntax:
{ "ends_with": [string, suffix] }
Arguments:
string- String to checksuffix- Suffix to look for
Returns: true if string ends with suffix, false otherwise.
Examples:
{ "ends_with": ["Hello World", "World"] }
// Result: true
{ "ends_with": ["Hello World", "Hello"] }
// Result: false
// Check file extension
{ "ends_with": [{ "var": "filename" }, ".pdf"] }
// Data: { "filename": "report.pdf" }
// Result: true
// Case sensitive
{ "ends_with": ["test.PDF", ".pdf"] }
// Result: false
Try it:
upper
Convert string to uppercase.
Syntax:
{ "upper": string }
Arguments:
string- String to convert
Returns: Uppercase string.
Examples:
{ "upper": "hello" }
// Result: "HELLO"
{ "upper": "Hello World" }
// Result: "HELLO WORLD"
// With variable
{ "upper": { "var": "name" } }
// Data: { "name": "alice" }
// Result: "ALICE"
// Unicode case mapping can change the length
{ "upper": "straße" }
// Result: "STRASSE"
Try it:
lower
Convert string to lowercase.
Syntax:
{ "lower": string }
Arguments:
string- String to convert
Returns: Lowercase string.
Examples:
{ "lower": "HELLO" }
// Result: "hello"
{ "lower": "Hello World" }
// Result: "hello world"
// Case-insensitive comparison
{ "==": [
{ "lower": { "var": "input" } },
"yes"
]}
// Data: { "input": "YES" }
// Result: true
// Context-sensitive rules: a final Greek capital sigma becomes ς
{ "lower": "ΟΔΟΣ" }
// Result: "οδος"
Try it:
Notes:
upperandlowerapply Unicode’s full case mappings (Rust’sstr::to_uppercase/str::to_lowercase), context-sensitive rules such as the Greek final sigma included
trim
Remove leading and trailing whitespace.
Syntax:
{ "trim": string }
Arguments:
string- String to trim
Returns: String with whitespace removed from both ends.
Examples:
{ "trim": " hello " }
// Result: "hello"
{ "trim": "\n\ttext\n\t" }
// Result: "text"
// Clean user input
{ "trim": { "var": "userInput" } }
// Data: { "userInput": " search query " }
// Result: "search query"
Try it:
split
Split a string into an array.
Syntax:
{ "split": [string, delimiter] }
Arguments:
string- String to splitdelimiter- Delimiter to split on
Returns: Array of substrings.
Examples:
// Split by space
{ "split": ["Hello World", " "] }
// Result: ["Hello", "World"]
// Split by comma
{ "split": ["a,b,c", ","] }
// Result: ["a", "b", "c"]
// Split by empty string (characters)
{ "split": ["abc", ""] }
// Result: ["a", "b", "c"]
// Parse CSV-like data
{ "split": [{ "var": "tags" }, ","] }
// Data: { "tags": "rust,json,logic" }
// Result: ["rust", "json", "logic"]
// Split an email address
{ "split": ["user@example.com", "@"] }
// Result: ["user", "example.com"]
Try it:
Notes:
splitreads exactly two arguments. There is no limit argument:{ "split": ["a,b,c,d", ",", 2] }still returns all four parts, and a single argument is Invalid Arguments- An empty input string gives
[""]
See Also
- Array Operators (
slice,merge,map,filter): slice and transform arrays produced bysplit - Comparison Operators (
==,===): compare strings lexicographically or with type coercion - Operators Overview: full operator table and feature flags
Array Operators
Operations for working with arrays, including iteration and transformation.
Feature flags (Rust crate). All array operators are baseline except
sort,slice,group_by, anddistinct, which require theext-arrayfeature. Every language binding enables all operator features. An engine built withEngineBuilder::with_familieshas those four only if it namesFamily::ExtArray. See the feature table.
The JSONLogic iterators (filter, map, reduce, all, some, none)
reject extra arguments with an Invalid Arguments error; sort, group_by and
distinct ignore them. Inside an iterator, {"var": ""} is the current
element; Scope levels reach enclosing
elements and the element’s index or key.
merge
Concatenate multiple arrays into one.
Syntax:
{ "merge": [array1, array2, ...] }
Arguments:
array1,array2, … - Arrays to concatenate;mergewraps scalars and objects as single elements
Returns: A single array: the arguments concatenated one level deep. Nested arrays inside an argument stay as elements (no deep flatten), and merge drops null arguments and null elements.
Examples:
// Merge two arrays
{ "merge": [[1, 2], [3, 4]] }
// Result: [1, 2, 3, 4]
// Merge multiple
{ "merge": [[1], [2], [3]] }
// Result: [1, 2, 3]
// Non-arrays are wrapped
{ "merge": [[1, 2], 3, [4, 5]] }
// Result: [1, 2, 3, 4, 5]
// With variables
{ "merge": [{ "var": "arr1" }, { "var": "arr2" }] }
// Data: { "arr1": [1, 2], "arr2": [3, 4] }
// Result: [1, 2, 3, 4]
// Only one level is flattened
{ "merge": [[1, [2, [3]]], [4]] }
// Result: [1, [2, [3]], 4]
// Nulls are dropped
{ "merge": [[1, null, 2], null] }
// Result: [1, 2]
Try it:
Notes:
- Flattens exactly one level; use nested
mergecalls for deeper structures nullarguments andnullelements are skipped; a scalar argument is wrapped ({ "merge": 1 }is[1])
filter
Filter array elements based on a condition.
Syntax:
{ "filter": [array, condition] }
Arguments:
array- Array to filter (an object is also accepted, see Notes)condition- Condition applied to each element (use{"var": ""}for current element)
Returns: Array of elements where condition is truthy. For an object input, an object of the key/value pairs whose value passes.
Examples:
// Filter numbers greater than 2
{ "filter": [
[1, 2, 3, 4, 5],
{ ">": [{ "var": "" }, 2] }
]}
// Result: [3, 4, 5]
// Filter even numbers
{ "filter": [
[1, 2, 3, 4, 5, 6],
{ "==": [{ "%": [{ "var": "" }, 2] }, 0] }
]}
// Result: [2, 4, 6]
// Filter objects by property
{ "filter": [
{ "var": "users" },
{ "==": [{ "var": "active" }, true] }
]}
// Data: {
// "users": [
// { "name": "Alice", "active": true },
// { "name": "Bob", "active": false },
// { "name": "Carol", "active": true }
// ]
// }
// Result: [{ "name": "Alice", "active": true }, { "name": "Carol", "active": true }]
// Filter with multiple conditions
{ "filter": [
{ "var": "products" },
{ "and": [
{ ">": [{ "var": "price" }, 10] },
{ "var": "inStock" }
]}
]}
Try it:
Notes:
filterdoes not modify the original array- A field an element lacks reads as
null, so{ "filter": [{ "var": "rows" }, { "===": [{ "var": "v" }, null] }] }keeps the elements withoutvas well as those whosevisnull filterfilters an object input by its values and returns an object of the kept pairs:{ "filter": [{ "var": "x" }, { ">": [{ "var": "" }, 3] }] }with{ "x": { "a": 1, "b": 5 } }is{ "b": 5 }- A
nullor missing input yields[]; a scalar input is an Invalid Arguments error
map
Transform each element of an array.
Syntax:
{ "map": [array, transformation] }
Arguments:
array- Array to transformtransformation- Operation applied to each element
Returns: Array of transformed elements.
Examples:
// Double each number
{ "map": [
[1, 2, 3],
{ "*": [{ "var": "" }, 2] }
]}
// Result: [2, 4, 6]
// Extract property from objects
{ "map": [
{ "var": "users" },
{ "var": "name" }
]}
// Data: {
// "users": [
// { "name": "Alice", "age": 30 },
// { "name": "Bob", "age": 25 }
// ]
// }
// Result: ["Alice", "Bob"]
// Create new objects
{ "map": [
{ "var": "items" },
{ "cat": ["Item: ", { "var": "name" }] }
]}
// Data: { "items": [{ "name": "A" }, { "name": "B" }] }
// Result: ["Item: A", "Item: B"]
// Square numbers
{ "map": [
[1, 2, 3, 4],
{ "*": [{ "var": "" }, { "var": "" }] }
]}
// Result: [1, 4, 9, 16]
Try it:
Notes:
- An object input maps over its values and returns an array (
{ "a": 1, "b": 5 }doubled is[2, 10]) - A
nullor missing input yields[];maptreats a scalar input as a one-element array ({ "map": [5, { "*": [{ "var": "" }, 2] }] }is[10])
reduce
Reduce an array to a single value.
Syntax:
{ "reduce": [array, reducer, initial] }
{ "reduce": [array, reducer] }
Arguments:
array- Array to reducereducer- Operation combining accumulator and current elementinitial- Initial value for accumulator (optional). When omitted, the first element seeds the accumulator and reduction starts from the second; an empty array withoutinitialyieldsnull
Returns: Final accumulated value.
Context Variables:
{"var": "current"}- Current element{"var": "accumulator"}- Current accumulated value- Dotted paths read inside them:
{"var": "current.price"},{"var": "accumulator.total"}
Examples:
// Sum all numbers
{ "reduce": [
[1, 2, 3, 4, 5],
{ "+": [{ "var": "accumulator" }, { "var": "current" }] },
0
]}
// Result: 15
// Product of all numbers
{ "reduce": [
[1, 2, 3, 4],
{ "*": [{ "var": "accumulator" }, { "var": "current" }] },
1
]}
// Result: 24
// Concatenate strings
{ "reduce": [
["a", "b", "c"],
{ "cat": [{ "var": "accumulator" }, { "var": "current" }] },
""
]}
// Result: "abc"
// Find maximum
{ "reduce": [
[3, 1, 4, 1, 5, 9],
{ "if": [
{ ">": [{ "var": "current" }, { "var": "accumulator" }] },
{ "var": "current" },
{ "var": "accumulator" }
]},
0
]}
// Result: 9
// Count elements matching condition
{ "reduce": [
[1, 2, 3, 4, 5, 6],
{ "+": [
{ "var": "accumulator" },
{ "if": [{ ">": [{ "var": "current" }, 3] }, 1, 0] }
]},
0
]}
// Result: 3 (count of numbers > 3)
// Without initial: the first element seeds the accumulator
{ "reduce": [
[1, 2, 3],
{ "+": [{ "var": "accumulator" }, { "var": "current" }] }
]}
// Result: 6
// Empty array without initial
{ "reduce": [
[],
{ "+": [{ "var": "accumulator" }, { "var": "current" }] }
]}
// Result: null
Try it:
Notes:
- An object input reduces over its values
- A
null, missing, or scalar input returnsinitialunchanged (nullwheninitialis omitted) - The accumulator may nest at most 1,024 levels of arrays and objects. A reducer that wraps the accumulator once per element (
[{ "var": "accumulator" }]) fails with an Invalid Arguments error once it passes that depth
all
Check if all elements satisfy a condition.
Syntax:
{ "all": [array, condition] }
Arguments:
array- Array to checkcondition- Condition applied to each element
Returns: true if all elements satisfy condition, false otherwise.
Examples:
// All positive
{ "all": [
[1, 2, 3],
{ ">": [{ "var": "" }, 0] }
]}
// Result: true
// All greater than 5
{ "all": [
[1, 2, 3],
{ ">": [{ "var": "" }, 5] }
]}
// Result: false
// All users active
{ "all": [
{ "var": "users" },
{ "var": "active" }
]}
// Data: { "users": [{ "active": true }, { "active": true }] }
// Result: true
// Empty array returns false (in this engine, all-of-empty is false)
{ "all": [[], { ">": [{ "var": "" }, 0] }] }
// Result: false
Try it:
Notes (all, some, none):
- Each stops at the first element that decides the result and does not evaluate the condition for the rest
- An object input checks the object’s values
- A
null, missing, or scalar input counts as an empty collection:allisfalse,someisfalse,noneistrue
some
Check if any element satisfies a condition.
Syntax:
{ "some": [array, condition] }
Arguments:
array- Array to checkcondition- Condition applied to each element
Returns: true if at least one element satisfies condition, false otherwise.
Examples:
// Any negative
{ "some": [
[1, -2, 3],
{ "<": [{ "var": "" }, 0] }
]}
// Result: true
// Any greater than 10
{ "some": [
[1, 2, 3],
{ ">": [{ "var": "" }, 10] }
]}
// Result: false
// Any admin user
{ "some": [
{ "var": "users" },
{ "==": [{ "var": "role" }, "admin"] }
]}
// Data: {
// "users": [
// { "role": "user" },
// { "role": "admin" }
// ]
// }
// Result: true
// Empty array returns false
{ "some": [[], { ">": [{ "var": "" }, 0] }] }
// Result: false
Try it:
none
Check if no elements satisfy a condition.
Syntax:
{ "none": [array, condition] }
Arguments:
array- Array to checkcondition- Condition applied to each element
Returns: true if no elements satisfy condition, false otherwise.
Examples:
// None negative
{ "none": [
[1, 2, 3],
{ "<": [{ "var": "" }, 0] }
]}
// Result: true
// None greater than 0
{ "none": [
[1, 2, 3],
{ ">": [{ "var": "" }, 0] }
]}
// Result: false
// No banned users
{ "none": [
{ "var": "users" },
{ "var": "banned" }
]}
// Data: { "users": [{ "banned": false }, { "banned": false }] }
// Result: true
// Empty array returns true
{ "none": [[], { ">": [{ "var": "" }, 0] }] }
// Result: true
Try it:
sort
Sort an array.
Syntax:
{ "sort": [array] }
{ "sort": [array, ascending] }
{ "sort": [array, ascending, key_extractor] }
Arguments:
array- Array to sort (a value that resolves to an array)ascending- Optional direction boolean:true(or omitted) sorts ascending,falsesorts descendingkey_extractor- Optional per-element expression that produces the sort key for each element
Returns: Sorted array.
Examples:
// Sort numbers (ascending by default)
{ "sort": [[3, 1, 4, 1, 5, 9]] }
// Result: [1, 1, 3, 4, 5, 9]
// Sort strings
{ "sort": [["banana", "apple", "cherry"]] }
// Result: ["apple", "banana", "cherry"]
// Sort descending
{ "sort": [{ "var": "nums" }, false] }
// Data: { "nums": [3, 1, 4, 1, 5, 9] }
// Result: [9, 5, 4, 3, 1, 1]
// Sort objects ascending by a key extractor
{ "sort": [
{ "var": "items" },
true,
{ "var": "price" }
]}
// Data: {
// "items": [
// { "name": "B", "price": 20 },
// { "name": "A", "price": 10 }
// ]
// }
// Result: [{ "name": "A", "price": 10 }, { "name": "B", "price": 20 }]
Try it:
Notes:
- The second argument is a direction boolean:
true(or omitted) sorts ascending,falsedescending. A non-boolean direction falls back to ascending. - The optional third argument is a per-element key extractor, evaluated with each element as its context.
sorthas noa/bcomparator form. - Numbers sort by exact value, so integers above 2^53 keep their order. Values of different types sort by type:
null, booleans, numbers, strings, arrays, then objects. Two arrays, or two objects, tie. - The sort is stable: elements that tie keep their input order. A key field an element lacks reads as
null, so that element ties with one whose field isnull. - A
nullor missing input yieldsnull(a literalnullargument is Invalid Arguments). This differs fromgroup_byanddistinct, which yield[]fornullinput. An object or scalar input is Invalid Arguments.
slice
Extract a portion of an array or string.
Syntax:
{ "slice": [collection, start] }
{ "slice": [collection, start, end] }
{ "slice": [collection, start, end, step] }
Arguments:
collection- Source array, or a string (sliced by character)start- Starting index (negative counts from end);nullmeans the default (the first element)end- Ending index, exclusive (optional, negative counts from end);nullmeans the default (the end of the collection)step- Optional stride (default1); a negative step walks backwards, so[null, null, -1]reverses
Returns: Array slice, or a string slice for string input.
Examples:
// From index 2 to end
{ "slice": [[1, 2, 3, 4, 5], 2] }
// Result: [3, 4, 5]
// From index 1 to 3
{ "slice": [[1, 2, 3, 4, 5], 1, 3] }
// Result: [2, 3]
// Last 2 elements
{ "slice": [[1, 2, 3, 4, 5], -2] }
// Result: [4, 5]
// First 3 elements
{ "slice": [[1, 2, 3, 4, 5], 0, 3] }
// Result: [1, 2, 3]
// Pagination
{ "slice": [
{ "var": "items" },
{ "*": [{ "var": "page" }, 10] },
{ "+": [{ "*": [{ "var": "page" }, 10] }, 10] }
]}
// Data: { "items": [...], "page": 0 }
// Result: first 10 items
// Step: every second element
{ "slice": [[1, 2, 3, 4, 5], 0, 5, 2] }
// Result: [1, 3, 5]
// Negative step reverses
{ "slice": [[1, 2, 3, 4, 5], null, null, -1] }
// Result: [5, 4, 3, 2, 1]
// Strings are sliced by character
{ "slice": ["hello", 1, 3] }
// Result: "el"
{ "slice": ["hello", null, null, -1] }
// Result: "olleh"
Try it:
Notes:
- A
nullor missing collection yieldsnull, without evaluating the bounds start,endandstepmust be integers ornull. A whole float (2.0) counts as an integer; a fractional bound (1.5, written or computed), a string ("1") or a boolean is aNaNerrorstepof0is an Invalid Arguments error, as is a collection that is neither an array nor a string
group_by
Collapse an array into groups on a computed key.
Syntax:
{ "group_by": [array, key_expression] }
Arguments:
array- Array to group (a value that resolves to an array)key_expression- Per-element expression that produces each element’s group key (evaluated with the element as its context, likesort’s key extractor)
Returns: An array of {"key": ..., "items": [...]} rows, which map, filter and sort take as input. Groups appear in order of first key occurrence, so the output is deterministic for a given input.
Examples:
// Group tasks by status
{ "group_by": [{ "var": "tasks" }, { "var": "status" }] }
// Data: { "tasks": [
// { "id": 1, "status": "open" },
// { "id": 2, "status": "done" },
// { "id": 3, "status": "open" }
// ]}
// Result: [
// { "key": "open", "items": [{ "id": 1, "status": "open" }, { "id": 3, "status": "open" }] },
// { "key": "done", "items": [{ "id": 2, "status": "done" }] }
// ]
// Group meetings by calendar date in the user's timezone
{ "group_by": [
{ "var": "meetings" },
{ "format_date": [{ "var": "start_time" }, "dd MMM yyyy", "Asia/Kolkata"] }
]}
// Result: [ { "key": "17 Aug 2026", "items": [ ... ] }, ... ]
// Group numbers by a computed key
{ "group_by": [[1, 2, 3, 4, 5], { "%": [{ "var": "" }, 2] }] }
// Result: [
// { "key": 1, "items": [1, 3, 5] },
// { "key": 0, "items": [2, 4] }
// ]
Try it:
Notes:
group_bykeeps keys as their evaluated values and does not stringify them: numbers, booleans,null, and objects group by deep equality.- Elements whose key expression misses (resolves to
null) group together under anullkey. nullor empty input yields[]. Non-array input (scalar or object) is an error.
distinct
Drop duplicate elements, by value or by a computed key.
Syntax:
{ "distinct": [array] }
{ "distinct": [array, key_expression] }
Arguments:
array- Array to deduplicate (a value that resolves to an array)key_expression- Optional per-element expression; when present,distinctdeduplicates by the computed key instead of by value
Returns: Array with duplicates removed. The first occurrence wins, so output preserves input order.
Examples:
// Dedup by value
{ "distinct": [[3, 1, 3, 2, 1]] }
// Result: [3, 1, 2]
// Strict equality: 1 and "1" stay distinct
{ "distinct": [[1, "1", 1]] }
// Result: [1, "1"]
// Dedup objects structurally
{ "distinct": [{ "var": "items" }] }
// Data: { "items": [{ "a": 1 }, { "a": 2 }, { "a": 1 }] }
// Result: [{ "a": 1 }, { "a": 2 }]
// Dedup by key: one row per id, first revision wins
{ "distinct": [{ "var": "rows" }, { "var": "id" }] }
// Data: { "rows": [
// { "id": 1, "rev": "a" },
// { "id": 2, "rev": "b" },
// { "id": 1, "rev": "c" }
// ]}
// Result: [{ "id": 1, "rev": "a" }, { "id": 2, "rev": "b" }]
Try it:
Notes:
- Equality is strict deep equality (the same predicate
inuses): mixed types never merge, arrays and objects compare structurally. An object counts as a datetime only in the single-key form{ "datetime": "..." }, so records that share adatetimefield but differ elsewhere stay distinct. nullor empty input yields[]. Non-array input is an error.
See Also
- Variable Access: Scope Levels: access enclosing elements and iteration
index/keyinsidemap,filter, andreduce - Object Operators (
keys,values,entries): turn objects into arrays for iteration - String Operators (
in,length,split): check membership withinor measure array length withlength - Operators Overview: full operator table and feature flags
Object Operators
Operations for taking objects apart: enumerate keys, values, or key/value rows.
These are the read-side complement of templating’s computed-key object
construction; entries in particular turns any object into rows that the
array vocabulary (map, filter, group_by, …) can iterate.
Feature flags (Rust crate). All object operators require the
ext-objectfeature. Every language binding enables all operator features. An engine built withEngineBuilder::with_familieshas them only if it namesFamily::ExtObject. See the feature table.
keys
List an object’s keys.
Syntax:
{ "keys": [object] }
Arguments:
object- A value that resolves to an object
Returns: Array of the object’s key strings, in stored order.
Examples:
{ "keys": [{ "var": "scores" }] }
// Data: { "scores": { "alice": 10, "bob": 7 } }
// Result: ["alice", "bob"]
Try it:
values
List an object’s values.
Syntax:
{ "values": [object] }
Arguments:
object- A value that resolves to an object
Returns: Array of the object’s values, in stored order.
Examples:
{ "values": [{ "var": "scores" }] }
// Data: { "scores": { "alice": 10, "bob": 7 } }
// Result: [10, 7]
// Sum an object's values
{ "reduce": [
{ "values": [{ "var": "scores" }] },
{ "+": [{ "var": "accumulator" }, { "var": "current" }] },
0
]}
// Result: 17
Try it:
entries
Turn an object into an array of {key, value} rows.
Syntax:
{ "entries": [object] }
Arguments:
object- A value that resolves to an object
Returns: Array of {"key": ..., "value": ...} objects, in stored order.
Examples:
{ "entries": [{ "var": "scores" }] }
// Data: { "scores": { "alice": 10, "bob": 7 } }
// Result: [
// { "key": "alice", "value": 10 },
// { "key": "bob", "value": 7 }
// ]
// Iterate an object with the array vocabulary
{ "map": [
{ "entries": [{ "var": "scores" }] },
{ "cat": [{ "var": "key" }, ": ", { "var": "value" }] }
]}
// Result: ["alice: 10", "bob: 7"]
Try it:
Notes (all three operators):
nullinput, including an absent field, yields[].- Any other non-object input (array, string, number, boolean) is an Invalid Arguments error.
- Keys come out in stored order, duplicates included, exactly as the object carries them. The order depends on how the data entered: JSON text parsed by the engine (the
ParsedDataand string entry points, and the Node, WASM, and C-based bindings) keeps source-text order and duplicate keys, while data passed as aserde_json::Valueor as a Python dict is key-sorted and duplicate-free (so{ "zed": 10, "alpha": 7 }comes out as["alpha", "zed"]). Do not rely on source-text order across input routes.
See Also
- Array Operators — iterate over
entriesrows withmap,filter,reduce, andgroup_by - Variable Access — read specific object fields with
var,val, andexists - Structured Objects (Templating) — construct new JSON objects dynamically
- Operators Overview — full operator catalog and feature-flag table
DateTime Operators
Operations for working with dates, times, and durations.
Feature flag (Rust crate). All datetime operators require the
datetimefeature (which pulls inchronoandchrono-tz). Every language binding enables it. See the feature table.
Argument counts: datetime and timestamp take one argument, parse_date and format_date two or three, date_diff three, and now none. A call with too few arguments raises InvalidArguments naming what is missing (for example "date_diff requires two dates and a unit"). The operators ignore arguments past the last one they read.
Datetime and duration values
The engine treats these values as datetimes and durations wherever an operator looks for one (comparisons, + / -, format_date, date_diff, type):
- A string that parses as an ISO 8601 / RFC 3339 datetime, such as
"2024-01-15T10:30:00Z","2024-01-15T10:30:00-05:00"or the naive"2024-01-15T10:30:00"(read as UTC), whether it is written in the rule, read from data or returned by another operator. - A string that parses as a duration, such as
"1d:2h:3m:4s","2h30m"or"36h". - The boundary forms
{"datetime": "..."}and{"timestamp": "..."}in data. (In a rule, the same object is a call to the operator of that name.) Only an object with that single key counts: a record such as{"datetime": "2024-01-01T00:00:00Z", "user": "alice"}is ordinary data, so==,===,inanddistinctcompare every field andtypereports"object".
The operators on this page and datetime arithmetic return datetimes as ISO 8601 strings and durations as "Xd:Xh:Xm:Xs" strings, so a result feeds straight into the next operator.
now
Get the current UTC datetime.
Syntax:
{ "now": [] }
Arguments: None
Returns: The current UTC time as an ISO 8601 datetime string.
Examples:
{ "now": [] }
// Result: "2024-01-15T14:30:00Z" (current time)
// Check if date is in the future
{ ">": [{ "var": "expiresAt" }, { "now": [] }] }
// Data: { "expiresAt": "2025-12-31T00:00:00Z" }
// Result: true or false depending on current time
// Check if event is happening now
{ "and": [
{ "<=": [{ "var": "startTime" }, { "now": [] }] },
{ ">=": [{ "var": "endTime" }, { "now": [] }] }
]}
Try it:
Notes:
{ "type": { "now": [] } }is"datetime"- The result is in UTC (
Z) - The engine reads the clock at evaluation time and never constant-folds
now, so a compiled rule sees a fresh time on every evaluation
datetime
Parse or validate a datetime value.
Syntax:
{ "datetime": value }
Arguments:
value- An RFC 3339 datetime string (2024-01-01T00:00:00Z, or with a+HH:MM/-HH:MMoffset; fractional seconds and a space instead ofTare accepted), or a naiveYYYY-MM-DDTHH:MM:SSstring, which the engine reads as UTC. Date-only strings (2024-01-01) and colon-less offsets (-0500,+05) fail withInvalid datetime format; useparse_datefor those and for custom formats
Returns: The argument unchanged once it parses, so the offset and spelling you passed are kept ("2024-01-01 00:00:00Z" comes back with its space). Any other value raises Invalid datetime format. { "type": { "datetime": "2024-01-01T00:00:00Z" } } is "datetime".
Examples:
// Parse ISO string
{ "datetime": "2024-01-01T00:00:00Z" }
// Result: "2024-01-01T00:00:00Z"
// With timezone offset
{ "datetime": "2024-01-01T10:00:00+05:30" }
// Result: "2024-01-01T10:00:00+05:30"
// Compare datetimes
{ ">": [
{ "datetime": "2024-06-15T00:00:00Z" },
{ "datetime": "2024-01-01T00:00:00Z" }
]}
// Result: true
// Add duration to datetime
{ "+": [
{ "datetime": "2024-01-01T00:00:00Z" },
{ "timestamp": "7d" }
]}
// Result: "2024-01-08T00:00:00Z"
Try it:
timestamp
Create or parse a duration value. Durations represent time periods (not points in time).
Syntax:
{ "timestamp": duration_string }
Arguments:
duration_string- Duration in format like “1d:2h:3m:4s”, partial like “1d”, “2h”, “30m”, “45s”, or compact like “1d2h3m4s”
Returns: The duration as a normalized "Xd:Xh:Xm:Xs" string.
Duration Format:
d- Daysh- Hoursm- Minutess- Seconds
Examples:
// Full duration format
{ "timestamp": "1d:2h:3m:4s" }
// Result: "1d:2h:3m:4s"
// Days only
{ "timestamp": "2d" }
// Result: "2d:0h:0m:0s"
// Hours only
{ "timestamp": "5h" }
// Result: "0d:5h:0m:0s"
// Minutes only
{ "timestamp": "30m" }
// Result: "0d:0h:30m:0s"
// Compare durations
{ ">": [{ "timestamp": "2d" }, { "timestamp": "36h" }] }
// Result: true (2 days > 36 hours)
// Duration equality
{ "==": [{ "timestamp": "1d" }, { "timestamp": "24h" }] }
// Result: true
// Compact form
{ "timestamp": "1d2h3m4s" }
// Result: "1d:2h:3m:4s"
// Units overflow into the next larger unit
{ "timestamp": "36h" }
// Result: "1d:12h:0m:0s"
Notes:
{ "type": { "timestamp": "1d" } }is"duration"- Units overflow into the next larger unit (
"1d:25h"becomes"2d:1h:0m:0s") - Negative (
"-1d"), fractional ("1.5h"), week ("1w"), and numeric (3600) inputs fail withInvalid duration format
Try it:
Duration Arithmetic
+ and - accept datetimes and durations, * scales a duration by a number in either order, and / divides a duration by a number:
// Multiply duration
{ "*": [{ "timestamp": "1d" }, 2] }
// Result: "2d:0h:0m:0s"
// Divide duration
{ "/": [{ "timestamp": "2d" }, 2] }
// Result: "1d:0h:0m:0s"
// Add durations
{ "+": [{ "timestamp": "1d" }, { "timestamp": "12h" }] }
// Result: "1d:12h:0m:0s"
// Subtract durations
{ "-": [{ "timestamp": "2d" }, { "timestamp": "12h" }] }
// Result: "1d:12h:0m:0s"
// Add duration to datetime
{ "+": [
{ "datetime": "2024-01-01T00:00:00Z" },
{ "timestamp": "7d" }
]}
// Result: "2024-01-08T00:00:00Z"
// Subtract duration from datetime
{ "-": [
{ "datetime": "2024-01-15T00:00:00Z" },
{ "timestamp": "7d" }
]}
// Result: "2024-01-08T00:00:00Z"
// Difference between two datetimes (returns duration)
{ "-": [
{ "datetime": "2024-01-08T00:00:00Z" },
{ "datetime": "2024-01-01T00:00:00Z" }
]}
// Result: "7d:0h:0m:0s"
// The result of datetime +/- duration is a UTC instant: the parsed offset is not carried through
{ "+": [
{ "datetime": "2024-01-01T10:00:00+05:30" },
{ "timestamp": "1d" }
]}
// Result: "2024-01-02T04:30:00Z"
// Any number of operands, durations on either side of the datetime
{ "+": ["1d", { "var": "d" }, "12h"] }
// Data: { "d": "2024-01-01T00:00:00Z" }
// Result: "2024-01-02T12:00:00Z"
{ "-": [{ "var": "d" }, "1d", "1d"] }
// Data: { "d": "2024-01-08T00:00:00Z" }
// Result: "2024-01-06T00:00:00Z"
+ and - fold left to right. Datetime plus duration (in either order) is a
datetime, datetime minus datetime is a duration, and two durations add or
subtract to a duration. A step with no meaning is a NaN error under the
default configuration: two datetimes added, a duration minus a datetime,
or a plain number among datetime operands.
Arithmetic on a datetime that carries an offset drops that offset: the
result renders as ...Z, and format_date with the bare "z" format
reports +0000 for it. To render such a result in a local zone, pass the
zone argument to format_date (for example "Asia/Kolkata", which gives
"10:00" for the offset example above with format "HH:mm").
parse_date
Parse a date string with a custom format into a datetime value.
Syntax:
{ "parse_date": [string, format] }
{ "parse_date": [string, format, timezone] }
Arguments:
string- Date string to parseformat- Format string using simplified tokenstimezone- Optional IANA zone name (e.g."Asia/Kolkata"). Without it, the engine reads naive input as UTC; with it, the engine reads the input as wall-clock time in that zone and resolves it to the corresponding UTC instant.
Returns: The parsed instant as an ISO 8601 datetime string in UTC. Input that does not match the format raises Failed to parse date.
Format Tokens:
| Token | Description | Example |
|---|---|---|
yyyy | 4-digit year | 2024 |
MMMM | full month name | January |
MMM | abbreviated month name | Jan |
MM | 2-digit month | 01-12 |
dd | 2-digit day | 01-31 |
HH | 2-digit hour (24h) | 00-23 |
mm | 2-digit minute | 00-59 |
ss | 2-digit second | 00-59 |
EEEE | full weekday name | Monday |
EEE | abbreviated weekday name | Mon |
Raw chrono % specifiers also pass through unchanged.
Examples:
// Parse US date format
{ "parse_date": ["12/25/2024", "MM/dd/yyyy"] }
// Result: "2024-12-25T00:00:00Z"
// Parse European format
{ "parse_date": ["25-12-2024", "dd-MM-yyyy"] }
// Result: "2024-12-25T00:00:00Z"
// Parse date only
{ "parse_date": ["2024-01-15", "yyyy-MM-dd"] }
// Result: "2024-01-15T00:00:00Z"
// Read a naive local time as New York wall clock (EDT in June)
{ "parse_date": ["2024-06-15 12:00:00", "yyyy-MM-dd HH:mm:ss", "America/New_York"] }
// Result: "2024-06-15T16:00:00Z"
// With variable
{ "parse_date": [{ "var": "dateStr" }, "yyyy-MM-dd"] }
// Data: { "dateStr": "2024-06-15" }
// Result: "2024-06-15T00:00:00Z"
Timezone notes:
- Zone offsets, DST included, come from the IANA table compiled into the crate (
chrono-tz); the engine reads no tzdata files at runtime. - An ambiguous local time (clocks rolled back, the wall-clock occurs twice) resolves to the earlier instant; a nonexistent one (spring-forward gap) raises
Nonexistent local time for timezone. - An unknown zone name written as a literal in the rule does not stop
Engine::compile: the call raisesInvalid Arguments(naming the operator, not the zone) when the rule is evaluated, andEngine::check/Engine::compile_checkedreport the zone before the rule runs. A zone computed at evaluation time, from data or from another operator such ascat, fails withUnknown timezone: <name>.
Try it:
format_date
Format a datetime as a string with a custom format.
Syntax:
{ "format_date": [datetime, format] }
{ "format_date": [datetime, format, timezone] }
Arguments:
datetime- Datetime value to formatformat- Format string using simplified tokens (same as parse_date)timezone- Optional IANA zone name (e.g."Asia/Kolkata"). When present,format_daterenders the instant as wall-clock time in that zone (DST-correct via the IANA table) instead of UTC.
Returns: Formatted date string. A first argument that is not a datetime raises Failed to format date. A raw % specifier that chrono does not know ("%Q") or a trailing lone % raises Invalid date format, whether the format is written in the rule or read from data.
Special Format:
z- Returns the timezone offset (e.g., “+0500”). Without a zone argument this is the source offset the datetime was parsed with; with a zone argument it is the target zone’s offset at that instant.zis honoured only when it is the entire format string. Inside a longer format it is emitted literally:"yyyy-MM-dd HH:mm z"on2024-01-01T10:00:00+05:30gives"2024-01-01 04:30 z".- Without a zone argument every other token renders the UTC instant, so
"HH:mm"on that same value gives"04:30"and the raw chrono%zgives+0000; the source offset is reachable only through the bare"z"format. To get wall-clock time plus offset in one string, pass the zone argument and use%zor%Z:"HH:mm %z"with"Asia/Kolkata"gives"10:00 +0530", and"HH:mm %Z"gives"10:00 IST".
Examples:
// Format as date only
{ "format_date": [{ "datetime": "2024-01-15T14:30:00Z" }, "yyyy-MM-dd"] }
// Result: "2024-01-15"
// Format as US date
{ "format_date": [{ "datetime": "2024-12-25T00:00:00Z" }, "MM/dd/yyyy"] }
// Result: "12/25/2024"
// Get timezone offset (the format must be exactly "z")
{ "format_date": [{ "datetime": "2024-01-01T10:00:00+05:00" }, "z"] }
// Result: "+0500"
// Without a zone argument the other tokens render the UTC instant
{ "format_date": [{ "datetime": "2024-01-01T10:00:00+05:30" }, "HH:mm"] }
// Result: "04:30"
// Wall-clock time plus offset in one string needs the zone argument
{ "format_date": [{ "datetime": "2024-01-01T10:00:00+05:30" }, "HH:mm %z", "Asia/Kolkata"] }
// Result: "10:00 +0530"
// Render an instant as a calendar date in a zone
{ "format_date": [{ "datetime": "2026-08-17T18:30:00Z" }, "dd MMM yyyy", "Asia/Kolkata"] }
// Result: "18 Aug 2026"
// Zone offset at that instant (DST-aware)
{ "format_date": [{ "datetime": "2024-07-15T12:00:00Z" }, "z", "America/New_York"] }
// Result: "-0400"
// Format current time
{ "format_date": [{ "now": [] }, "yyyy-MM-dd"] }
// Result: "2024-01-15" (current date)
// With variable
{ "format_date": [{ "var": "date" }, "dd/MM/yyyy"] }
// Data: { "date": "2024-12-25T00:00:00Z" }
// Result: "25/12/2024"
// An unknown chrono specifier is an error (catchable with try)
{ "format_date": [{ "datetime": "2024-01-01T00:00:00Z" }, "%Q"] }
// Result: error (Invalid date format)
Try it:
date_diff
Calculate the difference between two dates in a specified unit.
Syntax:
{ "date_diff": [date1, date2, unit] }
Arguments:
date1- First datetimedate2- Second datetimeunit- Unit of measurement:"days","hours","minutes","seconds", or"milliseconds"(lowercase). Any other value is an Invalid Arguments error:date_diff: unknown unit "weeks" (expected days, hours, minutes, seconds, or milliseconds)
Returns: Difference (date1 - date2) as an integer in the specified unit, truncated toward zero; negative when date1 is earlier than date2.
Examples:
// Days between dates
{ "date_diff": [
{ "datetime": "2024-12-31T00:00:00Z" },
{ "datetime": "2024-01-01T00:00:00Z" },
"days"
]}
// Result: 365
// Hours difference
{ "date_diff": [
{ "datetime": "2024-01-01T12:00:00Z" },
{ "datetime": "2024-01-01T00:00:00Z" },
"hours"
]}
// Result: 12
// Milliseconds
{ "date_diff": [
{ "datetime": "2024-01-01T00:00:01Z" },
{ "datetime": "2024-01-01T00:00:00Z" },
"milliseconds"
]}
// Result: 1000
// Negative when the first date is earlier
{ "date_diff": [
{ "datetime": "2024-01-01T00:00:00Z" },
{ "datetime": "2024-01-02T00:00:00Z" },
"days"
]}
// Result: -1
// Unknown units are an error (catchable with try)
{ "date_diff": [
{ "datetime": "2024-01-02T00:00:00Z" },
{ "datetime": "2024-01-01T00:00:00Z" },
"weeks"
]}
// Result: error (Invalid Arguments)
// With variables
{ "date_diff": [
{ "var": "end" },
{ "var": "start" },
"days"
]}
// Data: {
// "start": "2024-01-01T00:00:00Z",
// "end": "2024-01-15T00:00:00Z"
// }
// Result: 14
// Check if within 24 hours
{ "<": [
{ "date_diff": [{ "now": [] }, { "var": "timestamp" }, "hours"] },
24
]}
// Data: { "timestamp": "2024-01-15T10:00:00Z" }
// Result: true or false
// Days since creation
{ "date_diff": [
{ "now": [] },
{ "var": "createdAt" },
"days"
]}
Try it:
DateTime Patterns
Check if date is in the past
{ "<": [{ "var": "date" }, { "now": [] }] }
Check if date is in the future
{ ">": [{ "var": "date" }, { "now": [] }] }
Check if within time window
{ "and": [
{ ">=": [{ "now": [] }, { "var": "startTime" }] },
{ "<=": [{ "now": [] }, { "var": "endTime" }] }
]}
Add days to a date
{ "+": [
{ "var": "date" },
{ "timestamp": "7d" }
]}
Calculate days until expiration
{ "date_diff": [
{ "var": "expiresAt" },
{ "now": [] },
"days"
]}
Check if expired
{ "<": [{ "var": "expiresAt" }, { "now": [] }] }
See Also
- Arithmetic Operators —
+,-,*, and/with datetimes and durations - Comparison Operators —
<,<=,>,>=, and==on timestamps and durations - Error Handling Operators — catch invalid date or timezone formats with
try - Operators Overview — full operator catalog and feature-flag table
Missing Value Operators
Operators for checking whether data fields are absent.
Feature flags (Rust crate).
missingandmissing_someare baseline: every build has them. See the feature table.
missing
Check for missing fields in the data.
Syntax:
{ "missing": [key1, key2, ...] }
{ "missing": key }
Arguments:
key1,key2, … - Field names to check. An argument that is an array of names, or an expression that evaluates to one, adds each of them:{ "missing": [["a", "b"], "c"] }checksa,bandc
Returns: Array of missing field names.
Examples:
// Check single field
{ "missing": "name" }
// Data: { "age": 25 }
// Result: ["name"]
// Check multiple fields
{ "missing": ["name", "email", "phone"] }
// Data: { "name": "Alice", "phone": "555-1234" }
// Result: ["email"]
// All fields present
{ "missing": ["name", "age"] }
// Data: { "name": "Alice", "age": 25 }
// Result: []
// All fields missing
{ "missing": ["name", "age"] }
// Data: {}
// Result: ["name", "age"]
// Nested fields
{ "missing": ["user.name", "user.email"] }
// Data: { "user": { "name": "Alice" } }
// Result: ["user.email"]
// A key that exists with a null value counts as present
{ "missing": ["x"] }
// Data: { "x": null }
// Result: []
Notes:
missingreports only absent paths. A key that exists with anullor empty-string value counts as present, so{ "missing": ["x"] }with{ "x": null }or{ "x": "" }returns[]. This differs from json-logic-js, which also reports keys whose value isnullor""- Dot-separated names walk nested objects (
"user.email"), and numeric segments index arrays ("items.0") - Under
MissingVar::Error(see Missing Variables),missingandmissing_somestill return the absent paths; they never raiseVariableNotFound
Common Patterns
Require all fields:
{ "!": { "missing": ["name", "email", "password"] } }
// Returns true only if all fields are present
Check if any field is missing:
{ "!!": { "missing": ["name", "email"] } }
// Returns true if ANY field is missing
Conditional validation:
{ "if": [
{ "missing": ["required_field"] },
{ "throw": "Missing required field" },
"ok"
]}
Try it:
missing_some
Require that at least N of a set of fields are present; returns the missing ones only when fewer than N are present.
Syntax:
{ "missing_some": [minimum, [key1, key2, ...]] }
Arguments:
minimum- Minimum number of fields that should be present[key1, key2, ...]- Array of field names to check
Returns: Array of missing field names if fewer than minimum are present, empty array otherwise.
Examples:
// Need at least 1 of these contact methods
{ "missing_some": [1, ["email", "phone", "address"]] }
// Data: { "email": "a@b.com" }
// Result: [] (1 present, requirement met)
// Data: {}
// Result: ["email", "phone", "address"] (0 present, need at least 1)
// Need at least 2 of these
{ "missing_some": [2, ["name", "email", "phone"]] }
// Data: { "name": "Alice" }
// Result: ["email", "phone"] (only 1 present, need 2)
// Data: { "name": "Alice", "email": "a@b.com" }
// Result: [] (2 present, requirement met)
// Data: { "name": "Alice", "email": "a@b.com", "phone": "555" }
// Result: [] (3 present, exceeds requirement)
Notes:
- Presence follows the same rule as
missing: a key holdingnullor""counts as present - A fractional
minimumrounds up:2.5needs 3 present fields. A minimum of0or less is always met. Aminimumthat is not a number ("2",null,true) counts as1 - A minimum larger than the number of listed fields cannot be met, so the result lists every absent field
- With fewer than two arguments (
{ "missing_some": [1] }) the result is[]
Common Patterns
Require at least one contact method:
{ "!": { "missing_some": [1, ["email", "phone", "fax"]] } }
// Returns true if at least one contact method is provided
Flexible field requirements:
{ "if": [
{ "missing_some": [2, ["street", "city", "zip", "country"]] },
"Please provide at least 2 address fields",
"Address accepted"
]}
Require majority of fields:
{ "!": { "missing_some": [3, ["field1", "field2", "field3", "field4", "field5"]] } }
// Returns true if at least 3 of 5 fields are present
Try it:
Comparison: missing vs missing_some
| Scenario | missing | missing_some |
|---|---|---|
| All fields required | { "!": { "missing": [...] } } | n/a |
| At least N required | n/a | { "!": { "missing_some": [N, [...]] } } |
| Result | Every absent field | The absent fields while fewer than N are present, otherwise [] |
Integration with Validation
Form validation example:
{ "if": [
{ "missing": ["username", "password"] },
{ "throw": "VALIDATION_ERROR" },
{ "if": [
{ "missing_some": [1, ["email", "phone"]] },
{ "throw": "CONTACT_REQUIRED" },
"valid"
]}
]}
Conditional field requirements:
// If business account, require company name
{ "if": [
{ "==": [{ "var": "accountType" }, "business"] },
{ "!": { "missing": ["companyName", "taxId"] } },
true
]}
See Also
- Variable Access —
var,val, andexistsfor reading and checking single paths - Error Handling Operators — pair
missingwiththrowandtryin validation pipelines - Configuration — configure
MissingVar::Errorto fail on absentvarreads - Coming from json-logic-js — how
missingdiffers onnulland""fields - Operators Overview — full operator catalog and feature-flag table
Error Handling Operators
Operators for throwing and catching errors inside a rule.
Feature flag (Rust crate).
tryandthrowrequire theerror-handlingfeature. Every language binding enables it. See the feature table.
Argument counts: try takes one or more arguments ({ "try": [] } is null, and a single argument evaluates as itself with nothing caught). throw reads one argument; with none it throws { "type": "null" }.
try
Catch errors and provide fallback values.
Syntax:
{ "try": [expression, fallback] }
{ "try": [expression, catch_expression] }
{ "try": [expression, fallback1, fallback2, ...] }
Arguments:
expression- Expression that might throw an errorfallback- Value or expression to use if an error occurstrytries further arms in order: each arm runs only if every arm before it raised an error. Only the last arm evaluates with the error object as its context; intermediate arms see the ordinary data context
Returns: Result of expression if successful, or fallback value/expression result if an error occurs.
What counts as an error:
- Anything raised by
throw. - Errors raised by the engine itself: an unknown operator, invalid arguments
(for example
{ "max": ["a", 1] }), an integer division by zero, a datetime that fails to parse, an unknown timezone, and so on. - A missing variable is not an error under the default configuration:
varandvalreturnnullfor an absent path, sotrydoes not fall back on missing data. Use??orvar’s default argument for that (see Control Flow). An engine configured withEvaluationConfig::missing_varset toMissingVar::ErrorraisesVariableNotFoundinstead, andtrycatches it like any other error (see Missing Variables). - An exhausted operation budget
(
BudgetExceeded) is final:trypasses it on without running a fallback arm.
Context in Catch:
When try catches an error, the catch expression evaluates with the error
object as its context, so you read its fields via var / val:
- A string
throwproduces the error object{ "type": <string> }, so you read the message with{ "var": "type" }. - An object
throw(sourced from data) preserves its own keys, so fields such as{ "var": "code" }or{ "var": "message" }read those keys. - Any other thrown value becomes
{ "type": <its type name> }:{ "throw": 5 }gives{ "type": "number" }. - Engine-raised errors arrive as
{ "type": <message> }: an unknown operator gives{ "type": "Unknown Operator" }, bad operands give{ "type": "Invalid Arguments" }, an integer division by zero gives{ "type": "NaN" }, and other kinds carry their message text (for example{ "type": "Invalid datetime format" },{ "type": "Unknown timezone: Mars/Olympus" }, or, underMissingVar::Error,{ "type": "Variable not found: user.name" }). { "var": "" }returns the entire error object.
Examples:
// Simple fallback value
{ "try": [
{ "/": [10, 0] },
0
]}
// Result: 0 (division by zero caught)
// Expression that succeeds
{ "try": [
{ "+": [1, 2] },
0
]}
// Result: 3 (no error, normal result)
// Catch a string error: the string becomes the error object's "type" field
{ "try": [
{ "throw": "User not found" },
{ "cat": ["Error: ", { "var": "type" }] }
]}
// Result: "Error: User not found"
// Canonical pattern: read a thrown string back via "type"
{ "try": [
{ "throw": "Some error" },
{ "val": "type" }
]}
// Result: "Some error"
// Throw an object sourced from data, then read its fields by key
{ "try": [
{ "throw": { "var": "err" } },
{ "var": "code" }
]}
// Data: { "err": { "code": 404, "message": "User not found" } }
// Result: 404
// Engine errors are caught too: read the kind via "type"
{ "try": [{ "not_an_operator": [1] }, { "var": "type" }] }
// Result: "Unknown Operator"
{ "try": [{ "max": ["a", 1] }, { "var": "type" }] }
// Result: "Invalid Arguments"
// Multiple fallback arms: each runs only if the previous one raised
{ "try": [{ "throw": "a" }, { "throw": "b" }, "c"] }
// Result: "c"
// A missing variable is NOT an error, so try does not fall back
{ "try": [{ "var": "missing" }, "fallback"] }
// Data: {}
// Result: null
// Nested try: the inner arm re-throws a clearer message, the outer arm reads it
{ "try": [
{ "try": [
{ "/": [1, { "var": "d" }] },
{ "throw": "division failed" }
]},
{ "var": "type" }
]}
// Data: { "d": 0 }
// Result: "division failed"
// Data: { "d": 4 }
// Result: 0.25
Common Patterns
Safe division:
{ "if": [
{ "!": { "var": "denominator" } },
0,
{ "/": [{ "var": "numerator" }, { "var": "denominator" }] }
]}
// Data: { "numerator": 1 }
// Result: 0
// Data: { "numerator": 1, "denominator": 4 }
// Result: 0.25
try alone is not enough here. Only an integral-valued zero divisor (or a
non-numeric string) raises the NaN error that try can catch. A null,
false, "", or "0" divisor coerces to zero and follows
EvaluationConfig::division_by_zero, whose default (ReturnSaturated)
returns f64::MAX without raising, so
{ "try": [{ "/": [{ "var": "numerator" }, { "var": "denominator" }] }, 0] }
yields 1.7976931348623157e308 when denominator is missing. Configure
DivisionByZeroHandling::ThrowError if you want every zero divisor to reach
the try fallback; see Arithmetic for the full rule.
Default for a missing value (not a try job):
{ "??": [{ "var": "user.profile.settings.theme" }, "default-theme"] }
// Data: {}
// Result: "default-theme"
Under the default configuration var on a missing path returns null
rather than raising, so wrapping it in try never produces the fallback. Use ?? or the var default form,
{ "var": ["user.profile.settings.theme", "default-theme"] }.
Error logging pattern:
{ "try": [
{ "risky_operation": [] },
{ "cat": ["Operation failed: ", { "var": "type" }] }
]}
// Result: "Operation failed: Unknown Operator"
// risky_operation stands in for a custom operator. In the default engine the
// name is not registered, so the call itself raises "Unknown Operator" and is
// caught. For a string throw, the thrown text is in the "type" field; if the
// operation throws a structured object instead, read the relevant key (e.g.
// "message").
Try it:
throw
Throw an error with optional details.
Syntax:
{ "throw": message }
{ "throw": error_object }
Arguments:
message- Error message string. The string becomes the error object’stypefield, orerror_object- An error object value (sourced from data, or built in templating mode) with arbitrary keys such ascodeandmessage. A multi-key object written inline as a literal does NOT compile in the default engine, because the engine parses it as an operator map. A single-key literal such as{ "throw": { "type": "X" } }does not work either, even in templating mode:typeis an operator name, so it runs thetypeoperator on"X"and throws{ "type": "string" }. An error object carrying atypekey must come from data ({ "throw": { "var": "err" } }).
Returns: Never returns normally. The error ends the evaluation unless a try catches it.
Examples:
// Simple string error (the string lands in the error object's "type" field)
{ "throw": "Something went wrong" }
// Throws the error object { "type": "Something went wrong" }
// Error object sourced from data. A literal multi-key object written inline
// would be parsed as an operator map and fail to compile in the default engine.
{ "throw": { "var": "err" } }
// Data: { "err": { "code": "INVALID_INPUT", "message": "Age must be positive" } }
// Throws an error carrying the object's fields
// Richer error: build the object in your data (or enable templating mode) and
// throw it by reference.
{ "throw": { "var": "validationError" } }
// Data: {
// "validationError": {
// "code": "VALIDATION_ERROR",
// "message": "Invalid email format",
// "field": "email"
// }
// }
// Conditional throw (string form)
{ "if": [
{ "<": [{ "var": "age" }, 0] },
{ "throw": "Age cannot be negative" },
{ "var": "age" }
]}
// Data: { "age": -5 }
// Throws the error object { "type": "Age cannot be negative" }
// Data: { "age": 25 }
// Result: 25
Uncaught errors
An error that no try catches reaches your code as an Error. A throw
arrives with ErrorCode::Thrown (tag "Thrown") and the error object as
its payload (Error::thrown_value()). Error::operator() names the
innermost operator that failed (since 5.8.0), here throw, on every
evaluation path, traced or not. The serialized form of the error from
{ "+": [1, { "throw": "bad" }] }:
{"type": "Thrown", "message": "Thrown: {\"type\":\"bad\"}", "thrown": {"type": "bad"}, "operator": "throw", "node_ids": [3, 4]}
See Error in the API reference for the other fields.
Common Patterns
Validation with throw:
{ "if": [
{ "missing": ["name", "email"] },
{ "throw": "Required fields missing" },
"valid"
]}
Business rule enforcement:
{ "if": [
{ ">": [{ "var": "amount" }, { "var": "balance" }] },
{ "throw": "Amount exceeds balance" },
{ "-": [{ "var": "balance" }, { "var": "amount" }] }
]}
Type validation:
{ "if": [
{ "!==": [{ "type": { "var": "value" } }, "number"] },
{ "throw": "Expected number" },
{ "*": [{ "var": "value" }, 2] }
]}
Try it:
Error Handling Patterns
Graceful Degradation
Fallback chains over possibly-missing data belong to ??, not try, because
a missing var is null rather than an error:
{ "??": [
{ "var": "user.preferences.language" },
{ "var": "defaults.language" },
"en"
]}
// Data: { "defaults": { "language": "fr" } }
// Result: "fr"
// Data: {}
// Result: "en"
Reserve try for expressions that can raise: throw, an integer
division by zero, invalid arguments, an unknown operator, a datetime parse
failure.
Validation Pipeline
{ "try": [
{ "if": [
{ "!": { "var": "input" } },
{ "throw": "Input required" },
{ "if": [
{ "<": [{ "length": { "var": "input" } }, 3] },
{ "throw": "Minimum 3 characters" },
{ "var": "input" }
]}
]},
{ "cat": ["Validation error: ", { "var": "type" }] }
]}
// Data: {}
// Result: "Validation error: Input required"
// Data: { "input": "ab" }
// Result: "Validation error: Minimum 3 characters"
// Data: { "input": "abc" }
// Result: "abc"
Error Recovery with Fallback Operations
The variadic form tries each arm in turn:
{ "try": [
{ "primary_operation": [] },
{ "fallback_operation": [] },
"all operations failed"
]}
// Result: "all operations failed"
// primary_operation and fallback_operation stand in for custom operators. Each
// arm runs only if the previous one raised; in the default engine neither name
// is registered, so both raise "Unknown Operator" and the last arm is returned.
Collecting All Errors
JSONLogic has no native way to collect multiple errors, but you can structure validations to report every failure:
{ "filter": [
[
{ "if": [{ "missing": ["name"] }, "name is required", null] },
{ "if": [{ "missing": ["email"] }, "email is required", null] },
{ "if": [
{ "and": [
{ "!": { "missing": ["email"] } },
{ "!": { "in": ["@", { "var": "email" }] } }
]},
"invalid email format",
null
]}
],
{ "!==": [{ "var": "" }, null] }
]}
// Data: { "email": "foo" }
// Result: ["name is required", "invalid email format"]
This returns an array of error messages for all validation failures. To wrap
it in an object such as { "errors": [...] }, enable templating mode
(Engine::builder().with_templating(true), Cargo feature templating). The
default engine parses a top-level errors key as an operator call, which fails
with InvalidOperator.
See Also
- Control Flow Operators — use
??andiffor null-coalescing and branching without throwing - Missing Value Operators — check for required fields before throwing validation errors
- Configuration — configure
NanHandling,DivisionByZeroHandling, andMissingVar - Structured Objects (Templating) — build structured error payloads in rules
- Operators Overview — full operator catalog and feature-flag table
Tensor Operators
JSON has no tensor. Anything that marshals JSON into a model’s inputs and
its outputs back into JSON (an ONNX Runtime session, an edge request
encoder, an RL observation buffer) needs one value that is neither a
scalar nor a JSON array. The tensor feature adds it: an opaque
n-dimensional typed buffer (a dtype, a shape, and one row-major
contiguous byte payload), plus twenty operators that make, reshape, and
read one back.
Feature flags (Rust crate). All tensor operators require the
tensorfeature.tensor-halfalso enablesf16/bf16elements. Every language binding enablestensorbut nottensor-half. See the feature table.
Argument counts: each section’s syntax lists every argument an
operator reads, optional ones included. to_list, shape and dtype
take one; zeros, stack, concat, unstack, reshape, cast and
argmax two; full, rle_expand, one_hot and crop three; tensor
and transpose one or two; normalize and gather two or three;
scatter and pad three or four. See Errors and limits
for a call with the wrong count.
What this is not
There is no arithmetic here: no matmul, no convolution, no element-wise add. The family marshals data and nothing else. Every operator’s cost is proportional to the data it moves, which is what keeps it honest to price; an operator whose work is not proportional to its data (a matmul reads 2n² elements and does n³ multiplies) would break that property. Model execution belongs in the runtime you are feeding, not in the rules engine.
The wire form
A tensor crosses the JSON boundary as a single-key tagged object:
{ "tensor": { "dtype": "f32", "shape": [2, 2], "data": "AACAPwAAAEAAAEBAAACAQA==" } }
data is the raw little-endian payload in standard base64. This one form
is the operator call, the form the engine emits, and the form the decoder
accepts, so a serialized tensor pasted back into a rule
evaluates to the tensor it came from, and a tensor stored in your input
data decodes with {"tensor": [{"val": "..."}]}.
Because the text-returning bindings (WASM, Node, C and everything built on it) hand back JSON, tensors flow through them unchanged with no FFI change.
dtypes
bool, i8, u8, i16, u16, i32, u32, i64, u64, f16,
bf16, f32, f64. Names match case-insensitively, so
safetensors-style "F32" works.
The shape operators (stack, concat, unstack, reshape,
transpose, pad, crop, gather) move raw cells and never interpret
one, so they work on every dtype including f16 / bf16 with no extra
feature. zeros does too: an all-zero buffer is a valid value of every
dtype. The element operators need tensor-half before they will
touch f16 / bf16, and answer an unsupported-dtype error otherwise.
tensor
Build a tensor, or pass one through. The family’s entry point.
Syntax:
{ "tensor": [value, dtype] }
{ "tensor": [value] }
Arguments:
value: nested JSON arrays (or a bare scalar for a 0-d tensor), the tagged wire form, or an existing tensordtype: required for the nested-array form; the shape is inferred from the nesting
Returns: A tensor.
An element that does not fit the declared dtype (300 as u8) is an
error; the operator does not truncate it or replace it with an infinity.
Examples:
{ "tensor": [[[1, 2], [3, 4]], "f32"] }
// Result: a 2x2 f32 tensor
{ "tensor": [{ "val": "prediction" }] }
// Decodes a tagged tensor out of the input data
{ "reshape": [{ "tensor": [[1, 2, 3, 4], "u8"] }, [2, 2]] }
// A flat list plus a shape
zeros / full
Build a tensor of a declared shape.
Syntax:
{ "zeros": [shape, dtype] }
{ "full": [shape, dtype, value] }
Examples:
{ "zeros": [[2, 3], "i32"] } // 2x3 of 0
{ "full": [[2, 2], "f32", 1.5] } // 2x2 of 1.5
scatter
Sparse writes into an otherwise-zero tensor.
Syntax:
{ "scatter": [points, shape, dtype, value] }
Arguments:
points: array of coordinate arrays.[i, j]writesvalue(1 by default);[i, j, v]writesvvalue: optional default written value
scatter drops an out-of-range point without an error: the usual
producer is a detector emitting boxes in source coordinates that may fall
outside the target grid.
Examples:
{ "scatter": [[[0, 1], [1, 0]], [2, 2], "u8"] }
// [[0, 1], [1, 0]]
rle_expand
Run-length decode into a tensor, row-major.
Syntax:
{ "rle_expand": [runs, shape, dtype] }
runs is the flat [v0, n0, v1, n1, …] pairing. The run lengths must sum
to exactly the shape’s element count. A mask that decodes to the wrong
size is a producer bug, and zero-filling the remainder would hide it.
Examples:
{ "rle_expand": [[0, 2, 1, 2], [2, 2], "u8"] }
// [[0, 0], [1, 1]]
one_hot
Build a [len, depth] indicator matrix.
Syntax:
{ "one_hot": [indices, depth, dtype] }
An index outside 0..depth leaves its row all-zero.
Examples:
{ "one_hot": [[0, 2], 3, "u8"] }
// [[1, 0, 0], [0, 0, 1]]
stack / concat / unstack
Join and split along an axis. stack introduces a new axis (so the
result is one rank higher); concat joins along an existing one.
unstack is the inverse of stack. Negative axes count from the end.
Syntax:
{ "stack": [tensors, axis] }
{ "concat": [tensors, axis] }
{ "unstack": [tensor, axis] }
stack requires identical shapes; concat requires them to agree on
every axis but the joined one. Both require a common dtype. unstack
returns an array of tensors.
Examples:
{ "stack": [[{"tensor": [[1, 2], "u8"]}, {"tensor": [[3, 4], "u8"]}], 0] }
// A 2x2 tensor: [[1, 2], [3, 4]]
reshape
Reinterpret the same bytes under a new shape. The only operator here that copies nothing: the output shares its payload with the input.
Syntax:
{ "reshape": [tensor, shape] }
The element count must match exactly; there is no inferred -1
dimension.
transpose
Permute the axes. perm defaults to a full reversal, so a 2-d transpose
needs no second argument.
Syntax:
{ "transpose": [tensor] }
{ "transpose": [tensor, perm] }
pad / crop
Grow or cut every axis. crop is the inverse of pad.
Syntax:
{ "pad": [tensor, before, after, value] }
{ "crop": [tensor, offset, shape] }
before and after take one entry per axis. value defaults to 0.
A crop window must lie inside the input.
Examples:
{ "pad": [{ "tensor": [[1, 2], "u8"] }, [1], [2]] }
// [0, 1, 2, 0, 0]
gather
Select slices along an axis, in the order given, so it both reorders and resamples.
Syntax:
{ "gather": [tensor, indices, axis] }
axis defaults to 0. Negative indices count from the end. Every index
must be in range: unlike scatter, dropping one would change the output
shape without an error.
cast
Convert to another dtype. cast is the family’s only cross-dtype
conversion, and it is lossy.
Syntax:
{ "cast": [tensor, dtype] }
Narrowing saturates rather than wrapping (300 to u8 is 255, not
44), and NaN becomes 0. cast converts each value through f64, so
i64 / u64 elements above 2^53 lose their low bits. Casting to the
dtype a tensor already has returns it unchanged, with no trip through
f64.
normalize
(x − mean) × scale, always producing f32.
Syntax:
{ "normalize": [tensor, mean, scale] }
scale defaults to 1, so the two-argument form is a plain mean
subtraction. The output dtype is fixed because that is what the operation
is for: turning integer sensor or pixel data into the float range a model
expects. mean and scale are scalars; per-channel normalization is
unstack + normalize + stack.
Examples:
{ "normalize": [{ "tensor": [[0, 255], "u8"] }, 127.5, 0.5] }
// [-63.75, 63.75]
argmax
Index of the largest element along an axis, as plain JSON.
Syntax:
{ "argmax": [tensor, axis] }
Returns: Nested JSON arrays of indices, or a bare number when the input is 1-d. An index is something a rule goes on to compare and branch on, so it comes back as ordinary JSON rather than a tensor.
Ties go to the first occurrence, and NaN never wins.
Examples:
{ "argmax": [{ "tensor": [[[1, 9], [7, 3]], "f32"] }, 1] }
// [1, 0]
to_list
Expand into plain nested JSON arrays.
Syntax:
{ "to_list": [tensor] }
The general escape hatch, and expensive by design: this is the one
operator that turns a compact buffer back into one JSON node per element.
Reach for argmax, shape or a comparison first.
shape / dtype
Read the header.
Syntax:
{ "shape": [tensor] }
{ "dtype": [tensor] }
shape returns an array of numbers; dtype returns the wire name, which
is exactly what tensor, zeros, full and cast accept back.
Errors and limits
- Argument count. Every tensor operator checks its argument count
before it evaluates any argument: too few raises
missing argument, too many raisestoo many arguments(bothInvalidArguments). - Size cap.
zeros,full,scatter,rle_expand,one_hotandpadsize a new buffer from dimensions in the rule. Past 2^28 (268,435,456) elements, 2 GiB off64, they raiseInvalidArguments("tensor would hold more than 268435456 elements")before allocating, so{"zeros": [[1000000, 1000000], "f64"]}fails fast on an engine without an operation budget. - Two problems in one call. The arguments are evaluated in order, and
an error raised while evaluating a later argument wins over a coercion
error on an earlier one:
{"zeros": ["bad", {"throw": "x"}]}throwsx.
How a tensor behaves as a value
| Context | Behavior |
|---|---|
type | "tensor" |
| Truthiness | true unless the element count is 0, matching the empty-array rule. A 0-d tensor holds one element, so it is truthy |
== / != | Structural between two tensors: dtype, shape, and payload. Against any other type it is incompatible, which follows the loose_equality_errors config as an object does |
=== | Structural, with no coercion at all |
| Numeric coercion | None. A one-element tensor does not coerce the way a one-element array does; to_list is the explicit way out |
sort | Tensors rank after objects; two tensors order by dtype, then shape, then payload bytes |
map, array elements, object fields | Passed through untouched |
Tensor operators are never constant-folded and never memoized by the CSE
pass, even though they are pure functions of their arguments: folding
{"zeros": [[128, 128], "f32"]} would bake a 64 KB literal into the
compiled rule.
See Also
- Array Operators — manipulate ordinary JSON arrays before converting with
tensoror afterto_list - Operation Budget — how tensor operations charge per element
- Configuration — enable or disable
Family::Tensorat runtime - Operators Overview — full operator catalog and feature-flag table
flagd-Compat Operators
Two operators specified by the OpenFeature flagd in-process provider (fractional, semantic version) for feature-flag targeting. The implementation follows the canonical Go evaluator, so a flag definition that works under a flagd provider picks the same variants here.
Feature flag (Rust crate).
fractionalandsem_verrequire theflagdfeature (off by default in the Rust crate; enabled in every language binding). An engine built withEngineBuilder::with_familiesincludes them only whenFamily::Flagdis listed. See the feature table.datalogic-rs = { version = "5", features = ["flagd"] }
Argument counts: fractional takes one or more arguments; sem_ver takes exactly three.
Both operators return null on malformed input (a wrong argument count, an unparseable version, a missing targeting key, a bucket that is not a [variant, weight] array) rather than raising. flagd’s evaluator observes the null and falls back to the flag’s default variant; non-flagd callers can compose with ?? or if for the same effect. An error raised while evaluating an argument, such as a throw, still propagates.
fractional
Deterministic percentage bucketing for A/B tests and gradual rollouts. Buckets are sticky per bucketing key: the same input always lands in the same variant across runs.
Reference: flagd Fractional spec
Algorithm. MurmurHash3 x86-32 of the bucketing key, then bucket = (hash * total_weight) >> 32 and walk cumulative integer weight bands, as the Go evaluator’s core/pkg/evaluator/fractional.go does. The crate vendors the hash, so the feature adds no hashing dependency.
Two argument shapes:
1. Explicit bucketing key
The first argument evaluates to a string; the remaining args are [variant, weight] pairs.
{
"fractional": [
{ "cat": [{ "var": "$flagd.flagKey" }, { "var": "email" }] },
["red", 50],
["blue", 20],
["green", 30]
]
}
The canonical pattern concatenates $flagd.flagKey + email so the same email gets different variants on different flags, and a user isn’t locked into one cohort across your whole product.
2. Implicit bucketing key
Omit the first argument, or let it evaluate to null (for example a missing var). The operator parses any other non-string first argument, such as a number or boolean, as a bucket definition; since that argument is not an array, the whole call returns null. The bucketing key is built from the root context as flagKey + targetingKey (the order the flagd Go evaluator uses):
{
"fractional": [
["new-ui", 50],
["old-ui", 50]
]
}
targetingKey (a non-empty string) is required. $flagd.flagKey is optional: when it is absent the key is the targetingKey alone, which still buckets deterministically but loses the per-flag cohort separation. flagd in-process providers stamp both onto the context as:
{
"targetingKey": "alice@example.com",
"$flagd": { "flagKey": "header-color" }
}
Missing, empty, or non-string targetingKey in implicit form returns null: there’s no key to hash and flagd’s contract is to fall back to the default variant.
Weights
Weights are relative, not percentages: [50, 50] and [1, 1] produce identical splits because the operator divides by the total. This lets you grow a rollout from [1, 99] to [50, 50] to [99, 1] without renormalizing.
Weights must be non-negative integers. Omitted weights default to 1, so ["red"], ["blue"] is equivalent to ["red", 1], ["blue", 1]. A non-integer weight (a fraction such as 99.9, or a string such as "50") also falls back to 1, so [["a", 99.9], ["b", 0.1]] behaves exactly like [["a", 1], ["b", 1]]. Negative weights clamp to 0, and weights above i32::MAX (2,147,483,647) clamp to it, since flagd’s weights are 32-bit: two equal weights of 2^40 split like [1, 1]. If every weight is 0, the call returns null.
Composing with if
To bucket only the users who pass a precondition, put fractional in a branch of if:
{
"if": [
{ "in": ["@example.com", { "var": "email" }] },
{
"fractional": [
{ "cat": [{ "var": "$flagd.flagKey" }, { "var": "email" }] },
["new-ui", 50],
["old-ui", 50]
]
},
"old-ui"
]
}
sem_ver
Semantic-version comparison with the four normalizations the flagd spec calls for.
Reference: flagd SemVer spec
Syntax:
{ "sem_ver": [version1, operator, version2] }
Operators:
| Operator | Meaning |
|---|---|
= | Exact match |
!= | Not equal |
< | Less than |
<= | Less or equal |
> | Greater than |
>= | Greater or equal |
^ | Same major version (caret-style “compatible”) |
~ | Same major + minor (tilde-style “approximate”) |
Comparison follows SemVer 2.0 precedence, including pre-release ordering: 1.0.0-alpha < 1.0.0-beta < 1.0.0.
Input normalizations
The operator applies four normalizations to both version arguments before parsing, matching what the flagd evaluator and most other flagd providers do:
- Strip leading
v/V:"v1.2.3","V1.2.3", and"1.2.3"are all equivalent. - Pad partial versions:
"1"becomes"1.0.0","1.2"becomes"1.2.0". - Coerce numeric input:
1(a JSON number) is treated as the string"1", then padded. - Drop build metadata:
"1.2.3+build.7"is treated as"1.2.3". (SemVer 2.0 specifies build metadata is ignored when determining precedence.)
Examples
// Simple comparison
{ "sem_ver": [{ "var": "app_version" }, ">=", "1.2.0"] }
// Caret: same major
{ "sem_ver": [{ "var": "app_version" }, "^", "1.0.0"] }
// matches 1.0.0, 1.5.3, 1.99.99, but not 2.0.0
// Tilde: same major + minor
{ "sem_ver": [{ "var": "app_version" }, "~", "1.2.0"] }
// matches 1.2.0, 1.2.5, 1.2.99, but not 1.3.0
// v-prefixed input is handled transparently
{ "sem_ver": ["v1.2.3", "=", "1.2.3"] } // true
{ "sem_ver": ["1.2", "<", "1.2.1"] } // true (1.2 padded to 1.2.0)
{ "sem_ver": [1, "=", "v1.0.0"] } // true (int 1 coerced)
Gated rollout pattern
The common shape for shipping a feature only to clients on a recent version:
{
"if": [
{ "sem_ver": [{ "var": "app_version" }, ">=", "2.0.0"] },
{ "fractional": [
{ "cat": [{ "var": "$flagd.flagKey" }, { "var": "user_id" }] },
["new-checkout", 10],
["old-checkout", 90]
]},
"old-checkout"
]
}
Conformance
The conformance test suites live under crates/datalogic-rs/tests/suites/flagd/ and mirror the upstream Go test fixtures:
The release workflow runs the full suite on the tag before it publishes.
See Also
- Control Flow Operators — combine
fractionalandsem_verwithifand?? - String Operators — build bucketing keys with
cat - Use Cases & Examples — feature flag and rollout rule patterns
- Operators Overview — full operator catalog and feature-flag table
Rust (Native Crate)
datalogic-rs is the core: it implements everything the other bindings expose. Using the crate directly gives you the full API ladder, including the zero-copy tier no wrapper exposes.
Install
cargo add datalogic-rs
The default build has no dependency on serde_json and ships the 33 baseline operators. Opt into features as needed; all-operators turns on every operator family at once:
[dependencies]
datalogic-rs = { version = "5", features = ["all-operators", "serde_json", "templating", "trace"] }
See the feature matrix for what each flag adds.
Quick start
let result = datalogic_rs::eval_str(
r#"{">": [{"var": "x"}, 10]}"#,
r#"{"x": 42}"#,
).unwrap();
assert_eq!(result, "true");
A default engine backs the module-level helpers (eval, eval_str, eval_into, compile), so one-off evaluation needs no setup.
Five tiers, one engine
Pick the tier that matches your performance budget and trace requirements:
| Tier | API Entry Point | When to use |
|---|---|---|
| Tier 0 | eval_str, eval, eval_into, compile | Quick scripts, one-off execution |
| Tier 1 | Engine::eval* | Custom operators, non-default configs, templating mode |
| Tier 2 | Engine::session() + Session::eval* | Hot loops (APIs, message queues, bulk pipelines); reuses internal bump arenas |
| Tier 3 | Engine::evaluate(&Logic, data, &Bump) | Zero-copy evaluation with a caller-owned bumpalo::Bump arena |
| Tier 4 | Engine::trace() | Full AST execution paths for debuggers and visualizers (trace feature) |
Tiers 0 to 2 and the trace tier exist in every language binding; Tier 3 (caller-owned arena) is Rust-only.
Compile once, evaluate many
use datalogic_rs::Engine;
let engine = Engine::default();
let logic = engine.compile(r#"{">": [{"var": "x"}, 10]}"#).unwrap();
let mut session = engine.session();
for payload in inputs {
let result = session.eval_str(&logic, payload).unwrap();
}
Compiled Logic is Send + Sync: share it across threads via Arc (or Engine::compile_arc). A Session is Send but not Sync; open one per thread or task. To keep a session in a struct or hold it across an .await, use SharedSession, which holds the engine by Arc. See Thread Safety for Tokio and rayon patterns.
Inspecting rules
Before a rule runs, Engine::check lists every problem the engine can see in it, and Engine::compile_checked refuses a rule with errors. After compiling, Logic::facts reports the data paths the rule reads and the operators it calls, and Engine::operators describes every built-in operator. See Rule Analysis.
Where everything else is documented
- API Reference: the public types, methods, and error variants
- Starter Boilerplates: copy-paste Axum feature-flag endpoint
- docs.rs/datalogic-rs: rustdoc with feature badges
- Crate README: the deep-dive with per-tier performance profiles
- Runnable examples: ten CI-built examples from getting started to zero-copy input
- Custom Operators · Configuration · Rule Analysis · Operation Budget · Structured Objects · Thread Safety · Security & Sandboxing
API Reference
Core types and methods of the datalogic-rs crate, 5.9.0. For every item
and its feature badge, see docs.rs/datalogic-rs.
Public surface at a glance
The crate exposes five evaluation tiers, in order of caller control. Most callers want Tier 0 for ad-hoc work or Tier 2 for repeated evaluation.
| Tier | Entry point | Arena owner | Returns | Use when |
|---|---|---|---|---|
| 0 | datalogic_rs::eval_str / eval / eval_into / compile | lazy static Engine | String / OwnedDataValue / T / Logic | One-shot scripts, ad-hoc evaluation, no custom config |
| 1 | Engine::eval_str / eval / eval_into / eval_as | per-call Bump | String / OwnedDataValue / T | You need custom operators, config, or templating mode |
| 2 | Engine::session() → Session::eval* | session-owned Bump | owned or &DataValue<'a> | Hot loops, services, batch jobs |
| 3 | Engine::evaluate(&Logic, data, &Bump) | caller-owned Bump | &'a DataValue<'a> | Zero-copy result pipelines, custom pool strategies |
| 4 | Engine::trace() → TracedSession::* | per-call Bump (caller-owned for eval_borrowed) + trace buffer | TracedRun<R> | Debugging, visualisation, instrumentation |
Every binding exposes the same tier model; see each binding’s README for the language-idiomatic entry points.
Module-level helpers
For the simplest cases, skip the engine entirely:
let result = datalogic_rs::eval_str(
r#"{"==": [{"var": "x"}, 1]}"#,
r#"{"x": 1}"#,
).unwrap();
assert_eq!(result, "true");
pub fn compile<R: IntoLogic>(rule: R) -> Result<Logic>;
pub fn eval<R, D>(rule: R, data: D) -> Result<OwnedDataValue>;
pub fn eval_str<R, D>(rule: R, data: D) -> Result<String>;
#[cfg(feature = "serde_json")]
pub fn eval_into<T, R, D>(rule: R, data: D) -> Result<T>;
These delegate to a shared default engine (lazy OnceLock<Engine>).
Build your own Engine when you need custom operators, a non-default
config, templating, or a long-lived Session.
Engine
The configured engine. Compiles rules and evaluates them. Engine is
Send + Sync; share one through an Arc.
Creating an Engine
use datalogic_rs::{Engine, EvaluationConfig};
// Default engine.
let engine = Engine::new();
// Builder: set config, enable templating, register custom operators.
let engine = Engine::builder()
.with_config(EvaluationConfig::strict())
.with_templating(true) // requires feature = "templating"
.with_template_key_escape('$') // optional: `{"$type": ...}` emits the key `type`
.add_operator("my_op", MyOperator)
.with_constant_folding(true) // default; pass false to keep every operator visible in the compiled tree
.build();
Operator registration is builder-only: the Engine that build()
returns has a fixed operator set. See EngineBuilder.
Methods
compile
Compile a JSONLogic rule into reusable Logic.
pub fn compile<R: IntoLogic>(&self, rule: R) -> Result<Logic>;
pub fn compile_arc<R: IntoLogic>(&self, rule: R) -> Result<Arc<Logic>>;
pub fn compile_strict<R: IntoLogic>(&self, rule: R) -> Result<Logic>;
#[cfg(feature = "templating")]
pub fn compile_template<R: IntoLogic>(&self, rule: R) -> Result<Logic>;
compile uses the engine’s templating mode. compile_strict and
compile_template choose the mode for one compile instead, with the
engine’s custom operators, template key escape and folding setting, so
one engine can check a rule strictly and compile an output template.
compile_strict exists in every build; on an engine without templating
it is compile.
R: IntoLogic accepts &str (JSON-parsed), &String,
&OwnedDataValue / OwnedDataValue, and &serde_json::Value (gated
on feature = "serde_json"). compile_arc is
Arc::new(engine.compile(rule)?) in one call, for sharing a rule across
threads.
check / compile_checked
pub fn check<R: IntoLogic>(&self, rule: R, mode: CheckMode) -> Vec<Diagnostic>;
pub fn compile_checked<R: IntoLogic>(&self, rule: R) -> std::result::Result<Logic, CompileError>;
check reports every problem the engine can see in a rule before it
runs, each located by a JSON Pointer. compile_checked compiles a rule
only when check finds no error, and otherwise returns a CompileError
listing every diagnostic. See Rule Analysis.
eval / eval_str / eval_into / eval_as (one-shot)
Engine-owned arena per call. They differ only in the result type:
pub fn eval<R, D>(&self, rule: R, data: D) -> Result<OwnedDataValue>;
pub fn eval_str<R, D>(&self, rule: R, data: D) -> Result<String>;
pub fn eval_as<O: FromDataValue, R, D>(&self, rule: R, data: D) -> Result<O>;
#[cfg(feature = "serde_json")]
pub fn eval_into<T, R, D>(&self, rule: R, data: D) -> Result<T>;
R: IntoLogic and D: OwnedInput: data accepts &str, &String,
&OwnedDataValue / OwnedDataValue, &serde_json::Value (gated on
serde_json), and Roots. An owned String is not accepted;
pass &s. For eval_into, T: DeserializeOwned; the typical choices
are serde_json::Value (JSON-shaped boundary) or your own domain struct.
eval_as::<serde_json::Value> converts the result in one step where
eval_into::<serde_json::Value> builds a value and deserialises it again.
let result = engine.eval_str(
r#"{"+": [{"var": "x"}, 1]}"#,
r#"{"x": 41}"#,
)?;
assert_eq!(result, "42");
let value: serde_json::Value = engine.eval_into(
r#"{"+": [{"var": "x"}, 1]}"#,
r#"{"x": 41}"#,
)?;
evaluate (raw tier)
Hot-path evaluation against arena-resident data. The caller owns the
bumpalo::Bump; the result borrows from it.
pub fn evaluate<'a, D: EvalInput<'a>>(
&self,
compiled: &'a Logic,
data: D,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>>;
D accepts any of: &'a DataValue<'a>, DataValue<'a>, &'a str,
&'a String, &OwnedDataValue, &ParsedData (see
ParsedData), &serde_json::Value (under
feature = "serde_json"), or Roots.
use bumpalo::Bump;
use datalogic_rs::Engine;
let engine = Engine::new();
let compiled = engine.compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
let arena = Bump::new();
let result = engine.evaluate(&compiled, r#"{"x": 1}"#, &arena).unwrap();
assert_eq!(result.as_bool(), Some(true));
With feature = "budget", evaluate_metered(&compiled, data, &arena, budget)
runs the same evaluation under an explicit operation budget and returns a
Result<Metered<&DataValue>> (evaluate_metered_report returns a
failure-inclusive Metered<Result<&DataValue>>; Session::eval_metered
and Session::eval_metered_report are the session counterparts). See
Operation Budget.
session
Open a Session that owns a reusable arena.
pub fn session(&self) -> Session<'_>;
For a session that holds the engine by Arc instead of borrowing it, use
SharedSession.
truthy
Apply the engine’s configured TruthyEvaluator to an arena value. if,
and, or, ! and !! apply the same coercion, so custom operators
and callers of evaluate can decide truthiness the way the engine would.
pub fn truthy(&self, value: &DataValue<'_>) -> bool;
let compiled = engine.compile(r#"{"var": "items"}"#)?;
let arena = bumpalo::Bump::new();
let result = engine.evaluate(&compiled, r#"{"items": [1]}"#, &arena)?;
assert!(engine.truthy(result));
truthy_of
The same rules for a value in whatever representation the host holds:
&serde_json::Value, &OwnedDataValue, &ParsedData or &DataValue
(the sealed TruthyInput trait). Use it on an evaluated result instead
of re-implementing truthiness: it follows the configured evaluator, and
under the default rules an empty object is falsy, like an empty array.
pub fn truthy_of<V: TruthyInput>(&self, value: V) -> bool;
let result: serde_json::Value = session.eval_into(&compiled, &context)?;
if !engine.truthy_of(&result) {
return Err(rejected());
}
trace (feature = “trace”)
Open a TracedSession that records execution steps.
Every eval* on it returns a TracedRun<R> carrying the result, steps,
and compile-time expression tree, with the same R that Session would
return; the inputs differ from Session (see the
Trace API section).
#[cfg(feature = "trace")]
pub fn trace(&self) -> TracedSession<'_>;
to_builder
pub fn to_builder(&self) -> EngineBuilder;
A builder holding this engine’s custom operators, config, templating mode, template key escape, folding setting and families. Change what differs and build a new engine; both share the operator instances. See Custom Operators: rebuilding an engine.
Introspection helpers
pub fn config(&self) -> &EvaluationConfig
pub fn has_custom_operator(&self, name: &str) -> bool
pub fn custom_operator_names(&self) -> impl Iterator<Item = &str>
pub fn custom_operator_info(&self, name: &str) -> Option<CustomOperatorInfo>
pub fn builtin_operator_names(&self) -> impl Iterator<Item = &'static str>
pub fn operators(&self) -> impl Iterator<Item = OperatorInfo>
operators() describes each built-in operator as its table row declares
it: canonical name and aliases, family and gating feature, argument
counts, whether it reads the data context, its effect, its cost class,
and which argument (if any) runs under a pushed frame. See
Rule Analysis.
builtin_operator_names() reports every built-in key this engine
resolves as an operator: the baseline set plus the extension families
the build compiled in and the engine kept (see
Operator Families),
including the input aliases var, ?: and match. It is derived from
the compiler’s own lookup table, so it cannot drift from dispatch.
Together with custom_operator_names() it is the engine’s full
vocabulary, which authoring tools need under templating mode, where an
unknown key is not an error but echoes back as data.
custom_operator_info(name) returns what a registered custom operator
declares about itself through CustomOperator::info, or None when no
custom operator has that name.
EngineBuilder
Fluent constructor for Engine. Returned by Engine::builder() (or
Engine::to_builder()).
EngineBuilder::new()
.with_config(EvaluationConfig::default())
.with_templating(true) // feature = "templating"
.with_template_key_escape('$') // optional escape for operator-named template keys
.with_constant_folding(true) // default; disable to keep every operator visible
.with_families([Family::ExtString]) // optional: the core plus these families
.add_operator("name", MyOp) // typed operator
.add_operator("dyn", boxed_op) // also accepts Box<dyn CustomOperator> or Arc<T>
.try_add_operator("other", OtherOp)? // Err if a built-in answers to the name
.try_build()?; // or .build(), which cannot fail
| Method | Notes |
|---|---|
with_config(config) | The EvaluationConfig. |
with_templating(on) | Templating mode for compile; only effective with feature = "templating". |
with_template_key_escape(prefix) | Unset by default. See Configuration. |
with_constant_folding(on) | Default true. |
with_families(families) | The JSONLogic core plus the named Family values. See Operator Families. |
add_operator(name, op) | Registers any T: CustomOperator + 'static, Box<dyn CustomOperator> and Arc<T> included. A second registration under a name replaces the first. |
try_add_operator(name, op) | Refuses a name a built-in of the builder’s families answers to, or one that begins with the template key escape, with a ConfigurationError. |
check_operator_name(&name) | The same refusal without registering anything or consuming the builder. |
check_operator_names() | Checks every name try_add_operator took against the builder’s settings now. |
try_build() | build(), after check_operator_names(), and refusing a max_recursion_depth of 0. |
build() | Finalises the engine. |
add_operator accepts any name, but a built-in always wins, so an
operator registered as if or var never runs. The
Custom Operators
guide covers try_add_operator, try_build and sharing an operator
between engines through Arc.
with_constant_folding(false) suits tooling that walks the compiled tree
and would be surprised by {"+": [1, 2]} collapsing to a literal 3.
The one-shot trace entry points (TracedSession::eval_str, eval_into)
and TracedSession::compile compile with folding off whatever this
setting says; TracedSession::eval and eval_borrowed run the Logic
you pass.
Family
Family names an operator family: Core, DateTime, ExtString,
ExtArray, ExtObject, ExtControl, ErrorHandling, ExtMath,
Tensor, Flagd. Every variant exists in every build.
impl Family {
pub const ALL: &'static [Family];
pub const fn name(self) -> &'static str; // "ExtString", as OperatorInfo::family reports it
pub const fn is_compiled(self) -> bool; // whether the build has its Cargo feature
}
Logic
The compiled, reusable rule tree. Output of Engine::compile.
Send + Sync: wrap inArcto share across threads (or useEngine::compile_arcto do it in one step).- Immutable after construction.
| Method | Returns |
|---|---|
facts() | A Facts: what the rule reads, which operators it calls, whether its result depends only on its data. |
resolve_node_ids(&ids) | Vec<PathStep>: the breadcrumb of a structured Error translated into the source path of the failing node. |
compiled_on(&engine) | Whether that engine instance compiled the rule. |
pointer(id) / pointers() | The JSON Pointer each node was compiled from, for a rule compiled with TracedSession::compile; None / empty otherwise. |
is_static() | Whether the tree could be evaluated without a data context. |
is_constant() | Whether compilation folded the whole rule to a literal. |
cse_slot_count() | How many shared subexpressions the compiler memoises per evaluation. |
to_json() | The compiled rule as JSONLogic text (also its Display). Folded subexpressions appear as literals; the output compiles back to an equivalent rule. |
Any engine can evaluate any rule. On an engine other than the one that
compiled it, the rule looks its custom operators up by name, and a rule
whose constants were folded under different evaluation settings is
compiled again for that engine (once per distinct setting), so folded
constants follow the evaluating engine’s config. In 6.0 a rule will
evaluate only on its own engine; compiled_on finds the places that rely
on the lookup.
Facts
let rule = engine.compile(r#"{"if": [{"var": "user.vip"},
{"map": [{"var": "cart"}, {"var": "price"}]}, []]}"#)?;
let facts = rule.facts();
facts.reads(); // [cart, user.vip]: `DataPath`s from the root
facts.has_computed_reads(); // false: no path is computed at runtime
facts.reads_complete(); // true: `reads()` is everything the rule can read
facts.reads_data(); // true
facts.operators(); // ["if", "map", "val"]: canonical names
facts.custom_operators(); // []
facts.is_deterministic(); // true: no `now`, no custom operator
DataPath is one path as segments: segments(), is_root(),
covers(&other), and a Display that joins segments with dots. What
each answer covers, and what it leaves out, is in
Rule Analysis.
Session
Reusable evaluation handle that owns a bumpalo::Bump. The session
never auto-resets: you decide when to release arena memory back to
the start-of-chunk position. Construct via Engine::session().
let mut session = engine.session();
let result_str: String = session.eval_str(&compiled, data_json)?;
let result_owned: datalogic_rs::datavalue::OwnedDataValue =
session.eval(&compiled, data)?;
// feature = "serde_json"
let value: serde_json::Value = session.eval_into(&compiled, &serde_data)?;
// Zero-copy borrowed result; lives until the next &mut self call.
let view: &datalogic_rs::DataValue<'_> = session.eval_borrowed(&compiled, data)?;
session.reset(); // bound peak memory between batches
session.reset_with_capacity(64 * 1024);
let bytes = session.allocated_bytes();
let engine_ref: &Engine = session.engine();
Session::eval / eval_str / eval_into / eval_as accept any
EvalInput<'_>. eval_borrowed returns a &'a DataValue<'a> that
borrows from the session’s arena; the borrow checker ends it at the next
&mut self call. With feature = "budget",
eval_metered(&compiled, data, budget) returns a
Metered<OwnedDataValue>.
Session is Send and not Sync: one per thread or task.
SharedSession
Session<'engine, E = &'engine Engine> has a type parameter for how it
holds its engine. Engine::session() borrows it (Session<'_>, the
default). SharedSession, an alias for Session<'static, Arc<Engine>>,
holds an Arc<Engine> instead, so it is 'static + Send: you can store
it in a struct, move it to another thread or hold it across an .await
without borrowing the engine. Both forms have the same methods.
use std::sync::Arc;
use datalogic_rs::{Engine, SharedSession};
let engine = Arc::new(Engine::new());
let logic = engine.compile(r#"{"var": "x"}"#)?;
let mut session = SharedSession::new(Arc::clone(&engine)); // or SharedSession::from(engine)
let handle = std::thread::spawn(move || session.eval_str(&logic, r#"{"x": 1}"#));
assert_eq!(handle.join().unwrap()?, "1");
EvalInput
Sealed input adapter trait used by Engine::evaluate and the Session
methods, and the OwnedInput cousin used by the owned one-shot entry
points.
| Implementor | Cost |
|---|---|
&'a DataValue<'a> | Pass-through. |
DataValue<'a> | One arena alloc. |
&'a str | JSON parse via DataValue::from_str; unescaped strings borrow from the text. |
&'a String | JSON parse, same as &str. |
&OwnedDataValue | Viewed in place: array and object spines built in the arena, leaves borrowed. |
&'a ParsedData | Pass-through: the tree is already arena-resident. |
&serde_json::Value (feature = "serde_json") | Viewed in place, like an owned value. |
&Roots / Roots | One object node, plus each root’s own cost. |
The trait is sealed; external crates cannot add new shapes.
On the engine that compiled a rule, an owned, serde_json or Roots
input is brought in only along the paths the rule reads, when
Logic::facts says those reads are complete. A rule with a
computed path, a custom operator that may read the context, or a read of
the whole input sees the whole input, as do traced runs and evaluation on
another engine. JSON text and ParsedData are not projected.
Roots
Several values evaluated as the fields of one top-level object, without building that object. Each root is borrowed and viewed in place the way the engine views a single input of that type, so a host that keeps a payload and its metadata apart does not copy both into a combined value for every evaluation.
let roots = Roots::from([("data", &payload), ("metadata", &metadata)]);
session.eval_into::<serde_json::Value, _>(&compiled, &roots)?;
// Mixed representations, built up one root at a time:
let roots = Roots::new()
.root("data", &payload) // &serde_json::Value
.root("claims", &claims) // &OwnedDataValue
.root("config", &parsed); // &ParsedData
A rule reads {"var": "data.user"} exactly as it would from
{"data": payload, "metadata": metadata}. Names keep the order they were
first given in (the key order of {"var": ""}), and a repeated name
replaces its value in place. Roots is accepted wherever the engine
takes input, by reference or by value. Each root is a RootValue, built
through From from &serde_json::Value, &OwnedDataValue, &ParsedData
or &DataValue. insert(name, value), names(), len() and
is_empty() complete the type.
Against a json!({"data": data, "metadata": metadata}) merge built per
evaluation, Roots measured 7 to 10 times faster for payloads of 4 to
1,024 fields.
OwnedInput
The owned-entry-point cousin used by Engine::eval* and the module-level
helpers, where the engine creates and owns the arena per call. Also
sealed; the supported shapes are &str and &String (parsed into the
per-call arena), &OwnedDataValue and OwnedDataValue (viewed in
place), &serde_json::Value (feature = "serde_json", viewed in place),
and &Roots / Roots.
FromDataValue
Sealed result-side counterpart: the R in Session::eval*,
Engine::eval_as and TracedSession::eval* is projected out of the
arena through FromDataValue::from_arena(&DataValue) -> Result<R>.
Implemented for OwnedDataValue (deep clone), String (JSON
serialisation), and serde_json::Value (feature = "serde_json"). The
typed eval_into::<T> paths go through serde_json::Value and then
serde_json::from_value.
ParsedData
A parse-once data handle: a self-contained JSON document that owns its
own arena. Parse a payload once, then evaluate any number of rules
against it at zero per-call conversion cost (&ParsedData implements
EvalInput).
impl ParsedData {
pub fn from_json(json: &str) -> Result<Self>; // ParseError on malformed input
pub fn from_value(value: &serde_json::Value) -> Self; // feature = "serde_json"
pub fn from_owned(value: &OwnedDataValue) -> Self;
pub fn value(&self) -> &DataValue<'_>; // borrow the parsed tree
pub fn allocated_bytes(&self) -> usize; // input copy + tree
}
use datalogic_rs::{Engine, ParsedData};
use datalogic_rs::bumpalo::Bump;
let engine = Engine::new();
let data = ParsedData::from_json(r#"{"user": {"age": 34}}"#)?;
let adult = engine.compile(r#"{">=": [{"var": "user.age"}, 18]}"#)?;
let senior = engine.compile(r#"{">=": [{"var": "user.age"}, 65]}"#)?;
let arena = Bump::new();
assert_eq!(engine.evaluate(&adult, &data, &arena)?.as_bool(), Some(true));
assert_eq!(engine.evaluate(&senior, &data, &arena)?.as_bool(), Some(false));
ParsedData is Send (move it across threads) but not Sync; share
by cloning the source string or parsing once per thread.
DataValue / OwnedDataValue
DataValue<'a> is the arena-resident value tree:
enum DataValue<'a> {
Null,
Bool(bool),
Number(NumberValue),
String(&'a str),
Array(&'a [DataValue<'a>]),
Object(&'a [(&'a str, DataValue<'a>)]),
DateTime(...), // feature = "datetime"
Duration(...), // feature = "datetime"
Tensor(...), // feature = "tensor"
}
Both DataValue and OwnedDataValue are re-exported from the
datavalue crate. Use arena.alloc(...) to
return values from custom operators; use OwnedDataValue when you need a
heap-allocated owned tree (e.g. as the return of Engine::eval /
Session::eval).
EvaluationConfig
Configuration for evaluation behavior. The struct is #[non_exhaustive],
so you cannot build it with a struct literal from outside the crate.
Construct it via default() (or a preset) and chain the with_* setters:
// #[non_exhaustive]: fields shown for reference, not for direct literals.
pub struct EvaluationConfig {
pub arithmetic_nan_handling: NanHandling, // default: ThrowError
pub division_by_zero: DivisionByZeroHandling, // default: ReturnSaturated
pub loose_equality_errors: bool, // default: true
pub truthy_evaluator: TruthyEvaluator, // default: JavaScript
pub numeric_coercion: NumericCoercionConfig, // default: NumericCoercionConfig::default()
pub max_recursion_depth: u32, // default: 256
#[cfg(feature = "budget")]
pub ops_budget: Option<u64>, // default: None (unbounded)
pub missing_var: MissingVar, // default: Null
}
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::ThrowError)
.with_division_by_zero(DivisionByZeroHandling::ReturnSaturated)
.with_loose_equality_errors(true)
.with_truthy_evaluator(TruthyEvaluator::JavaScript)
.with_numeric_coercion(NumericCoercionConfig::default())
.with_max_recursion_depth(256)
.with_missing_var(MissingVar::Null);
Presets:
EvaluationConfig::default();
EvaluationConfig::safe_arithmetic();
EvaluationConfig::strict();
With feature = "serde_json", EvaluationConfig::from_json_str builds
one from the JSON object the bindings use. What each setting does is in
the Configuration guide.
NanHandling
pub enum NanHandling {
ThrowError, // default
IgnoreValue,
CoerceToZero,
ReturnNull,
}
DivisionByZeroHandling
pub enum DivisionByZeroHandling {
ReturnSaturated, // default: f64::MAX / MIN with the dividend's sign
ThrowError,
ReturnNull,
ReturnInfinity, // f64::INFINITY as a DataValue; null on the JSON-string paths
}
Applies to the float path only: an integer dividend over an integer zero
({"/": [10, 0]}) raises Thrown { type: "NaN" } under every setting,
because no in-range integer sentinel exists. A fractional dividend
({"/": [10.5, 0]}) takes the configured path. See
Division by Zero.
TruthyEvaluator
pub enum TruthyEvaluator {
JavaScript, // default
Python,
StrictBoolean,
Custom(Arc<dyn Fn(&OwnedDataValue) -> bool + Send + Sync>),
}
The Custom callback receives an &OwnedDataValue, so it needs no
serde_json. TruthyEvaluator::custom(f) wraps a closure without
spelling out the Arc::new(...):
let config = EvaluationConfig::default().with_truthy_evaluator(
TruthyEvaluator::custom(|v: &OwnedDataValue| {
v.as_i64().map(|n| n % 2 == 0).unwrap_or(false) // even integers are truthy
}),
);
MissingVar
#[non_exhaustive]
pub enum MissingVar {
Null, // default: a `var` / `val` that finds nothing is null
Error, // raises VariableNotFound naming the path
}
See Missing Variables.
CustomOperator Trait
pub trait CustomOperator: Send + Sync {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
ctx: &mut operator::EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>>;
fn info(&self) -> CustomOperatorInfo { CustomOperatorInfo::opaque() }
fn check(&self, args: &[OwnedDataValue]) -> std::result::Result<(), Diagnostic> { Ok(()) }
}
| Parameter | Notes |
|---|---|
args | Pre-evaluated arguments. The engine has already recursed into each arg’s expression tree. |
ctx | Opaque view into the engine’s evaluation context. Untouched by most operators. |
arena | Allocator for the current call. Use arena.alloc(...) for DataValue and arena.alloc_str(...) for strings, or the one-call helpers on ArenaExt. |
info declares what the engine may assume about the operator as a
CustomOperatorInfo (fields deterministic, reads_context,
min_args, max_args; built with opaque(), pure(),
reading_context() and with_args(min, max)). check validates a
call’s arguments as written for Engine::check. Box<dyn CustomOperator>
and Arc<T: CustomOperator + ?Sized> implement the trait and forward all
three methods. See Custom Operators.
ArenaExt
Extension trait on bumpalo::Bump (bring it into scope with
use datalogic_rs::ArenaExt;) that folds “build a DataValue, then
allocate it” into one call. It returns static singletons where it can
(null, bool, small integers, empty strings/arrays/objects), which
makes it the recommended way to return values from a custom operator.
pub trait ArenaExt<'a> {
fn null(&'a self) -> &'a DataValue<'a>;
fn bool(&'a self, b: bool) -> &'a DataValue<'a>;
fn i64(&'a self, n: i64) -> &'a DataValue<'a>;
fn f64(&'a self, n: f64) -> &'a DataValue<'a>;
fn string(&'a self, s: &str) -> &'a DataValue<'a>;
fn array(&'a self, items: &[DataValue<'a>]) -> &'a DataValue<'a>;
fn object(&'a self, pairs: &[(&'a str, DataValue<'a>)]) -> &'a DataValue<'a>;
}
use datalogic_rs::{ArenaExt, CustomOperator, DataValue, Result};
use datalogic_rs::operator::EvalContext;
struct Triple;
impl CustomOperator for Triple {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
let n = args.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
Ok(arena.f64(n * 3.0))
}
}
EvalContext
operator::EvalContext<'_, 'a> is an opaque view into the engine’s
evaluation context, passed to CustomOperator::evaluate. Most custom
operators don’t need it. It offers:
root_input(): the input the evaluation started from;depth(): the number of iteration frames enclosing operators pushed;charge(n): chargenoperations against the operation budget before doing work the node count does not reflect. Without thebudgetfeature it returnsOk(()), so you can call it unconditionally.
The stack layout stays private so it can change without breaking the trait contract.
Rule analysis types
Rule Analysis covers these in full.
| Type | Role |
|---|---|
CheckMode | Engine, Strict or Template: the mode Engine::check reads a rule in. |
Diagnostic | One problem: code, severity, message, pointer (RFC 6901), operator. Diagnostic::error(msg) / warning(msg) / at_argument(i) build one from a custom operator’s check. |
DiagnosticCode | Unparsable, UnknownOperator, NotAnOperator, ArgumentForm, ArgumentCount, InvalidTimezone, SimilarToOperator, Compile, OperatorCheck. |
Severity | Error or Warning. |
CompileError | What compile_checked returns: diagnostics: Vec<Diagnostic>. Implements std::error::Error. |
Facts / DataPath | What Logic::facts returns. See Facts. |
OperatorInfo / ScopedArg | What Engine::operators yields. |
Error
Structured error type:
#[non_exhaustive]
pub struct Error {
pub kind: ErrorKind,
/* private fields: operator, node_ids */
}
// Read the contextual metadata via accessor methods, not fields:
impl Error {
pub fn code(&self) -> ErrorCode; // the kind without its payload
pub fn tag(&self) -> &'static str; // code().as_str()
pub fn operator(&self) -> Option<&str>; // innermost failing operator, when known
pub fn node_ids(&self) -> &[u32]; // compiled-node breadcrumb, leaf-to-root
}
// ErrorKind variants carry `Cow<'static, str>` payloads (not `String`).
// The enum is #[non_exhaustive]: a `match` on `error.kind` needs a `_ =>` arm.
#[non_exhaustive]
pub enum ErrorKind {
InvalidOperator(Cow<'static, str>),
InvalidArguments(Cow<'static, str>),
VariableNotFound(Cow<'static, str>),
InvalidContextLevel(isize),
TypeError(Cow<'static, str>),
ArithmeticError(Cow<'static, str>),
Custom(CustomErrorSource),
ParseError(Cow<'static, str>),
Thrown(OwnedDataValue),
FormatError(Cow<'static, str>),
IndexOutOfBounds { index: isize, length: usize },
ConfigurationError(Cow<'static, str>),
BudgetExceeded { budget: u64, spent: u64 }, // raised only with feature = "budget"
}
Error::operator() names the innermost operator on the failing path,
custom operators included, on plain and traced runs alike; when no node
on the path has an operator name, the rule’s root operator stands in.
ErrorKind::BudgetExceeded exists in every build, so a match on it
compiles whatever features are on; only a budget build raises it.
Error implements serde::Serialize (in every build) as:
{
"type": "<KindTag>",
"message": "<Display>",
"operator": "<name>", // present only when known
"node_ids": [42, 13, 7], // present only when non-empty
// kind-specific extras: variable, level, thrown, index/length, budget/spent
}
Use error.code() to switch on the kind, error.thrown_value() for the
Thrown payload, and error.resolve_path(&compiled) to translate the
node_ids breadcrumb into source PathSteps.
To wrap a foreign std::error::Error into a Custom error:
"abc".parse::<i32>().map_err(datalogic_rs::Error::wrap)?;
Error::source() walks the inner chain unchanged.
ErrorCode
ErrorCode is an error’s kind without its payload: one variant per
ErrorKind variant, Copy, Eq, Hash and Ord, with every variant
present in every build. as_str() (also its Display) is the name
Error::tag() returns, which the bindings carry on the wire, and
FromStr parses it back, failing with UnknownErrorCode.
ErrorCode::ALL lists every code, and ErrorKind::code() gives the code
of a bare kind. The enum is #[non_exhaustive].
use datalogic_rs::{Engine, ErrorCode};
let engine = Engine::new();
let err = engine.eval_str(r#"{"/": [1, 0]}"#, "{}").unwrap_err();
assert_eq!(err.code(), ErrorCode::Thrown);
assert_eq!("Thrown".parse::<ErrorCode>(), Ok(ErrorCode::Thrown));
Error Constructors
Error::new(kind) // from an ErrorKind, no operator / node metadata
Error::invalid_operator(name)
Error::invalid_arguments(msg)
Error::variable_not_found(name)
Error::invalid_context_level(level)
Error::type_error(msg)
Error::arithmetic_error(msg)
Error::custom_message(msg) // string-only
Error::wrap(err) // any Error + Send + Sync + 'static
Error::parse_error(msg)
Error::thrown(value)
Error::format_error(msg)
Error::index_out_of_bounds(index, length)
Error::configuration_error(msg)
// Builder-style metadata (the engine sets these itself on errors that
// bubble out of an operator; only needed when constructing errors by hand):
error.with_operator(name) // impl Into<Cow<'static, str>>
error.with_node_ids(ids) // Vec<u32>, leaf-to-root
PathStep
Resolved entry returned by Logic::resolve_node_ids and
Error::resolve_path, root to leaf. #[non_exhaustive], with
Serialize / Deserialize:
pub struct PathStep {
pub node_id: u32, // matches Error::node_ids
pub operator: Option<String>, // None for plain values and arrays
pub arg_index: Option<u32>, // position in the parent's arguments; None at the root
pub json_pointer: String, // e.g. "/if/0/>/0"; "" for the root
}
json_pointer escapes tokens as RFC 6901 does (/ is ~1). For a rule
compiled with TracedSession::compile it is Logic::pointer, the
pointer into the rule as written.
Result Type
pub type Result<T> = std::result::Result<T, Error>;
Trace API (feature = “trace”)
TracedSession
Open via engine.trace(). Every eval* returns a
TracedRun<R> with the same R that
Session would return, but the inputs differ from Session:
the one-shot methods take a rule source rather than a compiled &Logic,
and there is no session-owned arena. TracedSession holds only a
reference to the engine; each owned call allocates a fresh Bump, and
eval_borrowed uses the caller’s.
impl TracedSession<'_> {
// Compile in this mode instead of the engine's (CheckMode::Strict / Template).
pub fn with_mode(self, mode: CheckMode) -> Self;
// Compile as the one-shot paths do (folding off) and record each node's JSON Pointer.
pub fn compile<R: IntoLogic>(&self, rule: R) -> Result<Logic>;
// Pre-compiled Logic + owned input; fresh per-call arena.
pub fn eval<D: OwnedInput>(&self, compiled: &Logic, data: D) -> TracedRun<OwnedDataValue>;
// Rule source (IntoLogic) + owned input; compiles with folding disabled.
pub fn eval_str<R: IntoLogic, D: OwnedInput>(&self, rule: R, data: D) -> TracedRun<String>;
#[cfg(feature = "serde_json")]
pub fn eval_into<T: DeserializeOwned, R: IntoLogic, D: OwnedInput>(&self, rule: R, data: D) -> TracedRun<T>;
// Pre-compiled Logic + arena input + caller-owned arena; borrowed result.
pub fn eval_borrowed<'a, D: EvalInput<'a>>(
&self,
compiled: &'a Logic,
data: D,
arena: &'a bumpalo::Bump,
) -> TracedRun<&'a DataValue<'a>>;
}
// Cargo.toml: datalogic-rs = { version = "5", features = ["trace"] }
let engine = datalogic_rs::Engine::new();
let run = engine.trace().eval_str(r#"{"+": [1, 2]}"#, r#"{}"#);
println!("{}", run.result.unwrap()); // 3
println!("{} steps", run.steps.len());
The pre-compiled paths (eval, eval_borrowed) inherit whatever shape
the Logic was compiled into: a rule from Engine::compile has its
constant subexpressions folded, so those operators record no step. For
full coverage, use eval_str / eval_into, or compile with
engine.trace().compile(rule) and evaluate that Logic as many times as
you need. A rule compiled that way also records, for every node id, the
JSON Pointer of the rule value it came from: logic.pointer(step.node_id)
places each ExecutionStep and ExpressionNode in the rule as written.
let engine = datalogic_rs::Engine::new();
let logic = engine.trace().compile(r#"{"if": [{"var": "a"}, 1, 2]}"#)?;
let run = engine.trace().eval(&logic, r#"{"a": true}"#);
assert_eq!(logic.pointer(run.steps[0].node_id), Some("/if/0"));
TracedRun<R> (feature = “trace”)
pub struct TracedRun<R> {
pub result: Result<R, Error>, // success and failure share one field
pub steps: Vec<ExecutionStep>,
pub expression_tree: ExpressionNode,
}
R is the same shape that Session::eval* would return:
OwnedDataValue for eval, String for eval_str, T for
eval_into::<T>, &'a DataValue<'a> for eval_borrowed.
pub struct ExecutionStep {
pub step_id: u32, // recording order
pub node_id: u32, // which compiled node ran
pub context: serde_json::Value, // scope data at this step
pub result: Option<serde_json::Value>,
pub error: Option<String>,
pub iteration_index: Option<u32>, // iterator bodies only
pub iteration_total: Option<u32>,
}
pub struct ExpressionNode {
pub id: u32, // matches ExecutionStep::node_id
pub expression: String, // JSON text of this sub-expression
pub children: Vec<ExpressionNode>,
}
Steps carry no timing data; they record which nodes ran, in what order, and with what context and result.
Full Example
use datalogic_rs::{Engine, EvaluationConfig, NanHandling};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let engine = Engine::builder()
.with_config(
EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue),
)
.build();
let compiled = engine.compile_arc(
r#"{"if": [{">=": [{"var": "score"}, 60]}, "pass", "fail"]}"#,
)?;
let mut session = engine.session();
for score in [85, 45, 60] {
let r = session.eval_str(&compiled, &format!(r#"{{"score": {}}}"#, score))?;
println!("{} -> {}", score, r);
session.reset();
}
Ok(())
}
See Also
- Rust Quick Start — evaluation tiers, input routes, and Cargo feature flags
- Configuration —
EvaluationConfigandEngineBuilderoptions - Custom Operators — implementing
CustomOperatorandArenaExt - Rule Analysis —
Engine::check,compile_checked,Logic::facts, andEngine::operators - Thread Safety — sharing
EngineandLogicacross threads and async tasks
Node.js (Native Binding)
@goplasmatic/datalogic-node is the native Node.js binding: the Rust core compiled per platform and loaded through napi-rs, with no WebAssembly in between. On Node servers it is the fast path.
Two npm packages, one engine. This package is for Node services that want maximum throughput.
@goplasmatic/datalogic-wasmis the WebAssembly build: it also runs under Node, but its home turf is browsers, edge runtimes, Deno, and Bun. Both run the same core and pass the same conformance battery.
Install
npm install @goplasmatic/datalogic-node
The prebuilt platform binaries ship as optionalDependencies, so npm pulls only the .node file matching your platform:
| Platform | Architectures |
|---|---|
| Linux (glibc) | x64, arm64 |
| Linux (musl) | x64, arm64 |
| macOS | x64, arm64 |
| Windows | x64, arm64 |
Requires Node 18 or newer. There is no build step and no WASM initialization: import and call.
If require('@goplasmatic/datalogic-node') or import fails with MODULE_NOT_FOUND, upgrade to 5.8.0 or newer (5.9.0 is current). Versions 5.1.1 through 5.7.1 were published without the umbrella package’s index.js loader and index.d.ts typings, although the per-platform packages were complete.
Quick start
Rules and data are plain JavaScript objects; results come back as JavaScript values:
import { apply } from '@goplasmatic/datalogic-node';
const result = apply(
{ if: [{ '>': [{ var: 'score' }, 50] }, 'pass', 'fail'] },
{ score: 75 }
);
// -> "pass"
Both arguments also accept JSON text: the binding parses a JS string passed as rule or data as JSON instead of treating it as a string value. To evaluate against a data document that is a JSON string, pass it encoded (rule.evaluate(JSON.stringify('hello'))), or hand the JSON text to the string-in methods (evaluateStr) or a DataHandle.
An object that crosses the boundary as a JS value, in either direction, comes back with its keys sorted. To keep key order, pass JSON text and call a string-out method (evalStr, evaluateStr, evaluateDataStr).
Compile once, evaluate many
For repeated evaluations of the same rule, compile once and hold the Rule instance:
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine();
const rule = engine.compile({ '+': [{ var: 'x' }, 1] });
for (const payload of inputs) {
console.log(rule.evaluate(payload));
}
Rule has no thread affinity on the Rust side, but napi class instances cannot be posted or transferred between worker_threads: a Rule sent through postMessage arrives as an empty plain object with no evaluate. Each worker must load the module and compile its own Rule (compiling is cheap). For parallelism from a single thread, rule.evaluateStrAsync(json) evaluates on the libuv pool (see Async evaluation).
The constructor takes an options bag and, separately, a map of custom operators:
const strict = new Engine({ config: { preset: 'strict' } });
const templated = new Engine({ templating: true });
const stringsOnly = new Engine({ families: ['ExtString'] });
const custom = new Engine({ strictOperatorNames: true }, {
double: (argsJson) => String(JSON.parse(argsJson)[0] * 2),
});
custom.compile({ double: [21] }).evaluate({}); // 42
preset and the other evaluation options nest under config, not at the top level. The options bag reads only templating, templateKeyEscape, config, strictOperatorNames and families; it ignores other top-level keys, so new Engine({ preset: 'strict' }) builds a default engine.
templateKeyEscapeis a single-character prefix, unset by default, that lets a template emit a key which would otherwise run as an operator: withnew Engine({ templating: true, templateKeyEscape: '$' }),{ $type: { var: 'x' } }yields{ type: 1 }rather than running thetypeoperator, and{ $$type: 1 }yields{ $type: 1 }. Anything other than a one-character string throwserrorType: 'InvalidArguments'. See Structured Objects.familieskeeps the engine to the JSONLogic core plus the operator families you name ('DateTime','ExtString','ExtArray','ExtObject','ExtControl','ErrorHandling','ExtMath','Tensor','Flagd'). Unset, the engine has every family. A family left out compiles its names as unknown operators, and a custom operator may take them.strictOperatorNames: truemakes a custom operator named like a built-in (length,var, an alias such as?:) throw at construction instead of registering one that never runs.
Unknown keys inside config, an unknown family name and a refused operator name throw errorType: 'ConfigurationError'. Configuration covers what each config option means, including missing_var: 'error', which makes a read of a missing variable throw VariableNotFound instead of returning null.
Templating mode per compile
engine.compileTemplate(rule) compiles in templating mode and engine.compileStrict(rule) outside it, whatever the engine was built with. Both use the engine’s custom operators and config, so one engine can check a condition strictly and build an output template:
engine.compileTemplate({ name: { var: 'user' }, active: true }).evaluate({ user: 'Alice' });
// { active: true, name: 'Alice' }
Checking a rule before it runs
compile rejects malformed JSON and structurally invalid rules (a multi-key object when templating is off), but it does not verify operator names: an unknown operator compiles and throws errorType: 'InvalidOperator' when you evaluate the rule. engine.check(rule, mode?) reports every problem the engine can see before the rule runs, each with a JSON Pointer into the rule; mode is 'engine' (the default), 'strict' or 'template':
engine.check({ if: [true, { vr: 'x' }, { map: [1] }] });
// [
// { code: 'UnknownOperator', severity: 'error', pointer: '/if/1', operator: 'vr',
// message: 'unknown operator `vr`; did you mean `var`?' },
// { code: 'ArgumentCount', severity: 'error', pointer: '/if/2', operator: 'map',
// message: '`map` takes exactly 2 arguments, not 1' }
// ]
An 'error' will fail; a 'warning' runs but is probably a mistake. engine.compileChecked(rule) compiles only a rule with no error diagnostic and otherwise throws an Error named CompileError whose diagnostics holds the same objects. Rule Analysis covers the diagnostic codes and check modes.
Sessions: hot-loop arena reuse
A Session reuses one bump arena across evaluations and resets between calls to bound peak memory. Open one per worker thread:
const sess = engine.session();
for (const payload of inputs) {
sess.evaluate(rule, payload);
}
Sessions hold non-Sync state and must not be shared between worker threads.
Data handles and typed results
A DataHandle is an immutable, pre-parsed JSON document. Parse a payload once and every evaluation against it skips JSON parsing; the typed session methods also skip the result round trip:
import { DataHandle } from '@goplasmatic/datalogic-node';
const handle = new DataHandle('{"age": 25, "status": "active"}'); // throws ParseError on bad JSON
const isAdult = engine.compile({ '>=': [{ var: 'age' }, 18] });
const nextAge = engine.compile({ '+': [{ var: 'age' }, 1] });
nextAge.evaluateData(handle); // 26 (JS value out, no parse per call)
nextAge.evaluateDataStr(handle); // "26" (JSON string out)
sess.evaluateData(isAdult, handle); // true (hot path: session arena + no parse)
sess.evaluateBool(isAdult, handle); // true (strict JSON boolean, else TypeMismatch)
sess.evaluateInt(nextAge, handle); // 26 (a whole number a JS number holds exactly, else TypeMismatch)
sess.evaluateFloat(nextAge, handle); // 26 (any JSON number, else TypeMismatch)
sess.evaluateTruthy(isAdult, handle); // true (JSONLogic truthiness, never mismatches)
evaluateBool, evaluateInt and evaluateFloat throw an EvaluateError with errorType: 'TypeMismatch' when the rule evaluated fine but the result has another type. evaluateNumber is the deprecated name of evaluateFloat and is removed in 6.0. Handles are per-thread like sessions: parse one per worker.
Batch evaluation
The batch entry points evaluate a whole set in one native call and report outcomes per item in the Promise.allSettled shape, so one bad input never poisons its neighbours:
const outcomes = sess.evaluateBatch(rule, [h0, h1, h2]); // one rule, many payloads
const flags = sess.evaluateMany([r0, r1], handle); // many rules, one payload
for (const [i, o] of flags.entries()) {
if (o.status === 'rejected') {
console.log(`rule ${i} failed: ${o.reason.message} (${o.reason.tag})`);
} else {
console.log(`rule ${i}: ${o.value}`); // result as a JSON string
}
}
Async evaluation
rule.evaluateStrAsync(dataJson) evaluates on the libuv thread pool and returns a Promise<string>. It is no faster per call than evaluateStr; it keeps large payloads’ parse + evaluate + serialize off the event loop:
const result = await rule.evaluateStrAsync('{"age": 25}');
String input only (a DataHandle is pinned to the JS thread). Rejections carry the same structured fields as synchronous throws.
Tracing
engine.evaluateWithTrace(logic, data, mode?) takes JSON strings and returns a JSON string with the same { result, expression_tree, steps, pointers, error?, structured_error? } envelope the WASM package produces, so the React debugger accepts output from either package:
const run = JSON.parse(engine.evaluateWithTrace('{"+": [1, 2, 3]}', 'null'));
run.result; // 6
run.steps.length; // 1 (one step per operator node; literals record no step)
run.pointers; // { "1": "/+/0", "2": "/+/1", "3": "/+/2", "4": "" }
pointers maps each node id to the JSON Pointer of the rule value it was compiled from, so a debugger places each step in the rule as written. mode is 'engine' (the default), 'strict' or 'template', so a rule you compile with compileTemplate can be traced as one. Failures do not throw: result is null, error carries the message, and structured_error the structured form. The binding compiles the rule with optimization disabled so every operator surfaces a step; use it for debugging, not hot paths.
Operator names
Tooling that validates or autocompletes rules can ask the binding for its vocabulary instead of keeping a hand-maintained list:
import { builtinOperatorNames, Engine } from '@goplasmatic/datalogic-node';
const names = builtinOperatorNames();
names.length; // 87: the 84 built-in operators plus the aliases var, ?:, match
names.includes('group_by'); // true
const engine = new Engine({}, { double: (a) => String(JSON.parse(a)[0] * 2) });
engine.customOperatorNames(); // ['double']
builtinOperatorNames() is derived from the compiler’s own lookup table, so it cannot drift from dispatch; engine.customOperatorNames() lists the operators registered on that engine. For an engine with every operator family, the union is the engine’s full vocabulary; engine.operators() follows an engine’s families.
Three more calls describe the engine and a rule without running anything:
engine.operators().find((op) => op.name === 'reduce');
// { name: 'reduce', aliases: [], family: 'Core', feature: null, min_args: 2, max_args: 3,
// reads_context: false, effect: 'pure', cost: 'per_item', scoped_arg: 1 }
engine.compile({ '+': [{ var: 'a.b' }, { var: 'c' }] }).facts();
// { reads: [['a', 'b'], ['c']], computed_reads: false, reads_complete: true,
// reads_data: true, operators: ['+', 'val'], custom_operators: [], deterministic: true }
engine.truthy({}); // false: an empty object is falsy, like an empty array
engine.truthy('[]'); // false: a string is JSON text
operators() is the operator catalogue: one row per built-in operator the engine evaluates, following its families. facts() lists the data paths a compiled rule reads (as segment lists) and the operators it calls; reads_complete is false when a path is computed at runtime or a custom operator runs, and deterministic is false for now and any custom operator. truthy() applies the engine’s configured truthiness.
Error handling
Failures throw plain JS Error instances with structured fields attached:
try {
rule.evaluate(data);
} catch (e) {
if (e.name === 'ParseError') {
// Malformed rule or data JSON
} else if (e.name === 'EvaluateError') {
console.log(e.errorType); // stable tag (e.g. "InvalidOperator", "Thrown")
console.log(e.operator); // innermost failing operator
console.log(e.nodeIds); // leaf-to-root breadcrumb
console.log(e.path); // resolved root-to-leaf step list
}
}
e.name is 'ParseError', 'EvaluateError', 'CompileError' (from compileChecked, with a diagnostics array) or 'InternalError'. An InternalError is a panic inside the native engine, caught at the binding boundary and thrown (or, for evaluateStrAsync, rejected) instead of aborting the process; the engine, rule and session stay usable, and it always means a bug worth reporting. A BudgetExceeded error carries budget and spent.
Missing data is not an error by default: { var: 'missing' } evaluates to null. An engine built with config: { missing_var: 'error' } throws errorType: 'VariableNotFound' instead.
API surface
| Symbol | Description |
|---|---|
apply(rule, data) | One-shot compile + evaluate |
builtinOperatorNames() | Every built-in operator name this build accepts (includes aliases) |
new Engine({ templating?, templateKeyEscape?, config?, strictOperatorNames?, families? }, customOperators?) | Engine with optional templating, template-key escape, evaluation config, operator-name check, operator families, and custom operators |
engine.compile(rule) | Compile to a reusable Rule |
engine.compileTemplate(rule) / engine.compileStrict(rule) | Compile in or outside templating mode, whatever the engine’s default |
engine.compileChecked(rule) / engine.check(rule, mode?) | Compile only a rule with no error diagnostic / list every diagnostic |
engine.eval(rule, data) / engine.evalStr(rule, data) | One-shot, JS value / JSON string out |
engine.evalMetered(rule, data, budget?) | One-shot with an operation count: { result, ops } |
engine.evaluateWithTrace(logic, data, mode?) | One-shot with execution trace (JSON strings in and out) |
engine.session() | Arena-reusing Session for hot loops |
engine.operators() / engine.truthy(value) | Operator catalogue / the engine’s truthiness |
engine.customOperatorNames() | Names of the custom operators registered on this engine |
new DataHandle(json) / DataHandle.fromValue(value) / handle.allocatedBytes | Parse a payload once (from JSON text or a JS value); arena bytes held by the handle |
rule.evaluate(data) / rule.evaluateStr(data) | Evaluate against one payload, JS value / JSON string out |
rule.evaluateData(handle) / rule.evaluateDataStr(handle) | Evaluate a pre-parsed handle |
rule.evaluateMetered(data, budget?) | Evaluate with an operation count |
rule.evaluateStrAsync(dataJson) | Evaluate on the libuv pool, returns Promise<string> |
rule.facts() | What the rule reads and calls |
sess.evaluate(rule, data) / sess.evaluateStr(rule, data) | Evaluate with arena reuse |
sess.evaluateData(rule, handle) / sess.evaluateDataStr(rule, handle) | Handle in, arena reuse; the Str form is the fastest path |
sess.evaluateBool / evaluateInt / evaluateFloat / evaluateTruthy (rule, handle) | Typed results (TypeMismatch for the strict three); evaluateNumber is the deprecated name of evaluateFloat |
sess.evaluateBatch(rule, handles) / sess.evaluateMany(rules, handle) | Batch evaluation, allSettled-style items |
sess.reset() / sess.allocatedBytes() | Explicit arena reset (optional); bytes held by the arena’s chunks |
The package README carries the same surface with the configuration key table; Configuration covers what each option means.
When to choose WASM instead
Choose @goplasmatic/datalogic-wasm when the code must run in a browser or edge runtime, or when you want one artifact across Node + browser. Choose this native package for Node services where throughput matters: the WASM build measures roughly 88× slower than the native core on the same benchmark workload.
Next steps
- Integration: Express for the compile-once, cache-by-version service pattern
- Starter Boilerplates for a minimal Express authorization middleware
- Framework integration patterns for React, Vue, and worker recipes (applicable to both JS packages)
- Operation Budget and Security & Sandboxing for running untrusted rules safely
- Use cases & examples and Thread safety
Installation
Two npm packages, one engine. This chapter covers
@goplasmatic/datalogic-wasm, the WASM build: pick it for browsers, edge runtimes, Deno, and anywhere portability matters. For Node.js servers, prefer the native@goplasmatic/datalogic-nodepackage (napi), which calls the Rust core without a WebAssembly layer.
The @goplasmatic/datalogic-wasm package provides WebAssembly bindings for the datalogic-rs engine, so you can evaluate JSONLogic from JavaScript and TypeScript.
Package Installation
# npm
npm install @goplasmatic/datalogic-wasm
# yarn
yarn add @goplasmatic/datalogic-wasm
# pnpm
pnpm add @goplasmatic/datalogic-wasm
The package declares Node 18 or newer (engines.node) for the Node.js target.
Build Targets
The package includes three build targets, one per environment:
| Target | Use Case | Init Required |
|---|---|---|
web | Browser ES Modules, CDN | Yes (await init()) |
bundler | Webpack, Vite, Rollup | No (instantiates on import; needs the bundler’s WASM ESM integration) |
nodejs | Node.js (CommonJS/ESM) | No |
Automatic Target Selection
The package’s exports field selects a target by export condition:
// Browser/Bundler - the `import` condition resolves to the web target
import init, { Engine } from '@goplasmatic/datalogic-wasm';
// Node.js - the `node` condition resolves to the nodejs target
const { Engine } = require('@goplasmatic/datalogic-wasm');
Explicit Target Import
If you need a specific target:
// Web target (ES modules with init)
import init, { Engine } from '@goplasmatic/datalogic-wasm/web';
// Bundler target (no init; the module instantiates on import)
import { Engine } from '@goplasmatic/datalogic-wasm/bundler';
// Node.js target
import { Engine } from '@goplasmatic/datalogic-wasm/nodejs';
The bundler target imports datalogic_wasm_bg.wasm as an ES module, so it needs Webpack’s experiments.asyncWebAssembly (or the equivalent in your bundler); see Bundler Configuration.
WASM Initialization
For browser and bundler environments, initialize the WASM module before you use any export:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
// Initialize once at application startup
await init();
// Then build an engine and evaluate
const engine = new Engine();
const result = engine.evalStr('{"==": [1, 1]}', '{}'); // "true"
On Node.js, use the exports right after import, with no initialization. Do not call
init()there: with the bare specifier, Node resolves to the CommonJSnodejstarget, so a default import bindsinitto the module namespace object andawait init()throwsTypeError: init is not a function. Code that has to run in both places should guard the call:import init, { Engine } from '@goplasmatic/datalogic-wasm'; if (typeof init === 'function') await init(); // browser: loads the module; Node: no-opOr import from
@goplasmatic/datalogic-wasm/nodejsand skipinit.
TypeScript Support
The package includes TypeScript declarations. No additional @types package is needed.
import init, { Engine, Rule } from '@goplasmatic/datalogic-wasm';
await init();
const engine = new Engine();
const rule: Rule = engine.compile('{"==": [1, 1]}');
const result: string = rule.evaluate('{}'); // "true"
The declarations mark the deprecated exports (evaluate, evaluateWithTrace, CompiledRule, Session.evaluateNumber) with @deprecated, so your editor flags them; see Deprecated APIs.
Bundle Size
The WASM binary is a single self-contained module: about 5.2 MB uncompressed and about 1.0 MB gzipped in the 5.9.0 release build. It compiles in the IANA timezone database used by the datetime feature’s format_date and parse_date timezone arguments. Size-sensitive embedders building from source can shrink it by setting CHRONO_TZ_TIMEZONE_FILTER to only the zones they need; see Building from source.
CDN Usage
For prototypes or simple pages, you can load the module from a CDN:
<script type="module">
import init, { Engine } from 'https://unpkg.com/@goplasmatic/datalogic-wasm@latest/web/datalogic_wasm.js';
async function run() {
await init();
const engine = new Engine();
console.log(engine.evalStr('{"==": [1, 1]}', '{}')); // "true"
}
run();
</script>
Next Steps
- Quick Start - Basic usage examples
- API Reference - Functions, classes, configuration, and error shapes
- Framework Integration - React, Vue, and bundler setup
Quick Start
The core patterns for using JSONLogic from JavaScript/TypeScript.
Basic Evaluation
Build an Engine once, then evaluate:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
// Initialize WASM (browser/bundler only; on Node the default import is not
// a function, so guard the call or import from '@goplasmatic/datalogic-wasm/nodejs')
if (typeof init === 'function') await init();
const engine = new Engine();
// Evaluate a simple expression
const result = engine.evalStr('{"==": [1, 1]}', '{}');
console.log(result); // "true"
Working with Data
Pass data as a JSON string for variable resolution:
// Access nested data
const logic = '{"var": "user.age"}';
const data = '{"user": {"age": 25}}';
const result = engine.evalStr(logic, data);
console.log(result); // "25"
// Multiple variables
const priceLogic = '{"*": [{"var": "price"}, {"var": "quantity"}]}';
const orderData = '{"price": 10.99, "quantity": 3}';
console.log(engine.evalStr(priceLogic, orderData)); // "32.97"
Compiled Rules
For repeated evaluation of the same logic, compile it once with engine.compile instead of passing it to evalStr on every call:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
if (typeof init === 'function') await init();
const engine = new Engine();
// Compile once
const rule = engine.compile('{">=": [{"var": "age"}, 18]}');
// Evaluate many times with different data
console.log(rule.evaluate('{"age": 21}')); // "true"
console.log(rule.evaluate('{"age": 16}')); // "false"
console.log(rule.evaluate('{"age": 18}')); // "true"
Parsing Results
Every call returns its result as a JSON string. Parse it for use in your application:
const result = engine.evalStr('{"+": [1, 2, 3]}', '{}');
const value = JSON.parse(result); // 6 (number)
// For complex results
const arrayResult = engine.evalStr('{"map": [[1,2,3], {"+": [{"var": ""}, 10]}]}', '{}');
const array = JSON.parse(arrayResult); // [11, 12, 13]
Conditional Logic
Use if for branching:
const gradeLogic = JSON.stringify({
"if": [
{ ">=": [{ "var": "score" }, 90] }, "A",
{ ">=": [{ "var": "score" }, 80] }, "B",
{ ">=": [{ "var": "score" }, 70] }, "C",
{ ">=": [{ "var": "score" }, 60] }, "D",
"F"
]
});
const rule = engine.compile(gradeLogic);
console.log(JSON.parse(rule.evaluate('{"score": 85}'))); // "B"
console.log(JSON.parse(rule.evaluate('{"score": 42}'))); // "F"
Array Operations
Process arrays with map, filter, and reduce:
// Filter items
const filterLogic = JSON.stringify({
"filter": [
{ "var": "items" },
{ ">": [{ "var": "price" }, 20] }
]
});
const data = JSON.stringify({
items: [
{ name: "Book", price: 15 },
{ name: "Phone", price: 299 },
{ name: "Pen", price: 5 },
{ name: "Headphones", price: 50 }
]
});
const result = JSON.parse(engine.evalStr(filterLogic, data));
// [{ name: "Phone", price: 299 }, { name: "Headphones", price: 50 }]
Templating Mode
Compile a multi-key object with compileTemplate to use it as a JSON template:
const template = JSON.stringify({
"user": {
"fullName": { "cat": [{ "var": "firstName" }, " ", { "var": "lastName" }] },
"isAdult": { ">=": [{ "var": "age" }, 18] }
},
"timestamp": { "now": [] }
});
const data = JSON.stringify({
firstName: "Alice",
lastName: "Smith",
age: 25
});
// compileTemplate: multi-key objects become output templates for this rule
const result = JSON.parse(engine.compileTemplate(template).evaluate(data));
// {
// "user": { "fullName": "Alice Smith", "isAdult": true },
// "timestamp": "2024-01-15T10:30:00Z" (the current time, ISO 8601 UTC)
// }
To make templating the default for every compile, build the engine with new Engine({ templating: true }).
Error Handling
Wrap evaluations in try-catch. Failures throw a real Error whose name is a stable tag ("ParseError", "InvalidOperator", "Thrown", …); see Error Handling for the full shape:
try {
const result = engine.evalStr('{"invalid": "json', '{}');
} catch (error) {
console.error('Evaluation failed:', error.name, error.message);
// Evaluation failed: ParseError Parse error: json parse error at byte 17: unexpected end of input
}
To catch mistakes such as a misspelled operator before a rule runs, compile it with engine.compileChecked(logic), which throws a CompileError listing every problem, or call engine.check(logic) for the list itself; see check.
Debugging
Use engine.evaluateWithTrace for step-by-step debugging:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
if (typeof init === 'function') await init();
const engine = new Engine();
const trace = engine.evaluateWithTrace(
'{"and": [{"var": "a"}, {"var": "b"}]}',
'{"a": true, "b": false}'
);
const traceData = JSON.parse(trace);
console.log('Result:', traceData.result); // Result: false
console.log('Steps:', traceData.steps.length); // Steps: 3 (var a, var b, and)
Each step records the node id, the data context it saw, and its result (or error). Literal operands do not record steps.
Next Steps
- Installation - Build targets (
web,bundler,nodejs) and WASM initialization - API Reference - Complete class, method, configuration, and error documentation
- Framework Integration - React, Vue, Next.js, and bundler setup
- Node.js Native Binding - Faster native N-API binding for Node.js servers
- React Visual Debugger - Ready-made React rule editor and step-through debugger
API Reference
API documentation for the @goplasmatic/datalogic-wasm WebAssembly package.
The package exports the builtinOperatorNames function, four classes (Engine, Rule, Session, DataHandle), and, on the web target only, the default-exported init loader and initSync. It also still exports the deprecated evaluate and evaluateWithTrace functions and the CompiledRule class; see Deprecated APIs. Rules, data and results cross the boundary as JSON strings, except where a method below says otherwise; see Input/Output Types.
Functions
init()
Initialize the WebAssembly module. Required before using any other export in browser/bundler environments (the web target). It is the module’s default export.
type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
function init(options?: { module_or_path: InitInput | Promise<InitInput> }): Promise<InitOutput>;
Parameters:
options.module_or_path(optional) - Custom WASM source (URL, Response, BufferSource, or a compiledWebAssembly.Module). Defaults todatalogic_wasm_bg.wasmnext to the JS glue.
Returns: Promise that resolves when initialization is complete
Example:
import init from '@goplasmatic/datalogic-wasm';
// Standard initialization
await init();
// Custom WASM location
await init({ module_or_path: '/custom/path/datalogic_wasm_bg.wasm' });
Passing the source positionally (init('/path/to.wasm')) still works, but wasm-bindgen deprecates it and logs a console warning; use the object form. The web target also exports initSync({ module }), which instantiates synchronously from bytes or a compiled WebAssembly.Module you already hold (in a Worker, for example).
Node.js does not require initialization, and
initis not a function there. The package’snodeexport condition resolves the bare specifier to the CommonJSnodejstarget, so a default import (import init from '@goplasmatic/datalogic-wasm') bindsinitto the module namespace object andawait init()throwsTypeError: init is not a function. Code that has to run in both places should guard the call:import init, { Engine } from '@goplasmatic/datalogic-wasm'; if (typeof init === 'function') await init(); // no-op on NodeOr import the Node target (
@goplasmatic/datalogic-wasm/nodejs) and skipinit.
builtinOperatorNames()
Every built-in operator name this WASM build accepts, in the engine’s registry order.
function builtinOperatorNames(): string[];
Returns: Array of operator keys. The list includes the input aliases (var for val, ?: for if, match for switch), so it has 87 entries for the 84 built-in operators. It is derived from the compiler’s own lookup table, so tooling (editors, linters, palettes) can validate rules against the engine instead of a hand-maintained list. The list excludes custom operators (see engine.customOperatorNames()) and lists every family whatever an engine’s families option says; engine.operators() follows the engine.
Example:
import { builtinOperatorNames } from '@goplasmatic/datalogic-wasm';
const names = builtinOperatorNames();
names.length; // 87
names.includes('group_by'); // true
names.includes('var'); // true (alias of val)
names.includes('preserve'); // false (removed in v5)
Classes
Engine
A configurable engine: templating mode, an evaluation config, operator families, and custom operators. It compiles rules into Rule objects and opens Sessions.
Constructor
new Engine(options?: {
templating?: boolean;
templateKeyEscape?: string;
customOperators?: Record<string, (argsJson: string) => string>;
config?: string | object;
strictOperatorNames?: boolean;
families?: string[];
})
Parameters:
templating- Make templating mode the default for every rule this engine compiles (multi-key objects compile to output-shaping templates).compileTemplate/compileStrictchoose per rule.templateKeyEscape- Single-character prefix that marks a template key as a literal output field rather than an operator invocation. Unset by default. With'$',{"$type": ...}emits the keytypeinstead of running thetypeoperator, and{"$$type": ...}emits a literal$type. Only meaningful in templating mode; anything other than a one-character string throws. See Structured ObjectscustomOperators- Map of operator name to callback. Each callback receives the pre-evaluated arguments as a JSON-array string and must return a JSON-value string (null/undefinedcount as JSONnull). A thrown exception or a non-string return becomes a runtime evaluation error. Built-ins win: registering a built-in name (+,if,var, …) has no effect.config- Evaluation config; see Engine configurationstrictOperatorNames- Whentrue, a custom operator named like a built-in (aliases included) throws aConfigurationErrorinstead of being registered and never running. Defaults tofalse.families- The operator families the engine has besides the JSONLogic core:"DateTime","ExtString","ExtArray","ExtObject","ExtControl","ErrorHandling","ExtMath","Tensor","Flagd"(thefamilyof eachoperators()row). Unset, the engine has every family. A family left out is not there for this engine: its names compile as unknown operators (failing at evaluation withInvalidOperator, and errors incheck), count as output fields in templating mode, and are free for a custom operator.
Throws: ConfigurationError for an unknown config key or value, an unknown family name, or a built-in name under strictOperatorNames; ParseError (stage: "parse-options") for a malformed options bag.
Example:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
await init();
const engine = new Engine({
customOperators: {
double: (argsJson) => String(JSON.parse(argsJson)[0] * 2),
},
});
engine.evalStr('{"double": [21]}', '{}'); // "42"
const stringsOnly = new Engine({ families: ['ExtString'] });
stringsOnly.evalStr('{"upper": ["a"]}', '{}'); // '"A"'
stringsOnly.evalStr('{"now": []}', '{}'); // throws Error { name: "InvalidOperator", operator: "now" }
Methods
compile(logic: string): Rule
Compile a rule against this engine (its config and custom operators apply), in the engine’s templating mode.
Throws: ParseError for malformed JSON; InvalidOperator for a multi-key object outside templating mode. compile does not reject unknown operator names: they surface as an InvalidOperator error when you evaluate the rule. Use compileChecked to refuse them up front.
const rule = engine.compile('{">=": [{"var": "age"}, 18]}');
rule.evaluate('{"age": 21}'); // "true"
compileTemplate(logic: string): Rule
Compile logic in templating mode, whatever this engine was built with: a multi-key object is an output template and an unknown key an output field.
const engine = new Engine();
engine.compileTemplate('{"name": {"var": "user"}, "active": true}').evaluate('{"user": "Alice"}');
// '{"name":"Alice","active":true}'
compileStrict(logic: string): Rule
Compile logic outside templating mode, whatever this engine was built with: a multi-key object is an error.
compileChecked(logic: string): Rule
Compile logic in the engine’s mode only if check finds no error diagnostic.
Throws: an Error named CompileError whose diagnostics property holds every problem (the same objects check returns).
try {
engine.compileChecked('{"if": [{"bogus": 1}, {"map": [1]}]}');
} catch (e) {
e.name; // "CompileError"
e.diagnostics.map((d) => d.pointer); // ["/if/0", "/if/1"]
}
check(logic: string, mode?: string): string
Every problem the engine can see in logic before it runs, in one pass, as a JSON array of diagnostics. mode is "engine" (the default: the engine’s own templating mode), "strict" or "template"; any other value throws a ParseError.
interface Diagnostic {
code: string; // "UnknownOperator", "NotAnOperator", "ArgumentForm", "ArgumentCount",
// "InvalidTimezone", "SimilarToOperator", "Unparsable", "Compile", ...
severity: 'error' | 'warning';
message: string; // names the operator involved
pointer: string; // RFC 6901 JSON Pointer into the rule; "" is the whole rule
operator: string | null;
}
Errors are what will fail: an object with several keys outside templating mode, an unknown operator (with a “did you mean” suggestion one edit away), and / or / if without an argument array, an argument count the operator rejects, a literal timezone that does not exist. Warnings run but are probably mistakes: arguments an operator never evaluates, and in a template an output key one edit away from an operator name. Malformed JSON is an Unparsable diagnostic, not a throw.
JSON.parse(engine.check('{"if": [true, {"vr": "x"}, {"map": [1]}]}'));
// [
// { code: "UnknownOperator", severity: "error", message: "unknown operator `vr`; did you mean `var`?",
// pointer: "/if/1", operator: "vr" },
// { code: "ArgumentCount", severity: "error", message: "`map` takes exactly 2 arguments, not 1",
// pointer: "/if/2", operator: "map" }
// ]
engine.check('{"a": {"var": "x"}, "b": 1}', 'template'); // "[]"
Rule Analysis covers the diagnostics, check modes, rule facts and the operator catalogue across the engine and every binding.
evalStr(logic: string, data: string): string
One-shot: compile logic and evaluate it against data, returning the result as a JSON string.
engine.evalStr('{"var": "name"}', '{"name": "Alice"}'); // "\"Alice\""
engine.evalStr('{"map": [[1,2,3], {"+": [{"var": ""}, 1]}]}', '{}'); // "[2,3,4]"
evalMetered(logic: string, data: string, budget?: number): string
One-shot metered evaluation. Returns a JSON string {"result": <value>, "ops": <number>}. budget caps the operations the rule may charge for this call; omit it to use the engine’s config.ops_budget, and omit both to meter without a cap. A budget must be a whole number >= 1. Crossing it throws an Error named BudgetExceeded carrying budget and spent; see Operation Budget.
engine.evalMetered('{"map":[{"var":"xs"},{"*":[{"var":""},2]}]}', '{"xs":[1,2,3]}');
// '{"result":[2,4,6],"ops":10}'
evaluateWithTrace(logic: string, data: string, mode?: string): string
Evaluate with a step-by-step execution trace, honoring this engine’s templating flag, config, and custom operators. mode is "engine" (the default), "strict" or "template", as for check, so a rule you compile with compileTemplate can be traced as one. The engine compiles the rule with optimization disabled so every operator records a step; use it for debugging, not hot paths.
Returns: JSON string containing a TracedResult:
interface TracedResult {
result: any; // Evaluation result (null on failure)
expression_tree: ExpressionNode; // Compile-time tree of the expression
steps: Step[]; // Execution steps, in evaluation order
pointers?: Record<string, string>; // Node id -> JSON Pointer into the rule as written
error?: string; // Present on failure: the message
structured_error?: StructuredError; // Present on failure: the structured form
}
interface ExpressionNode {
id: number; // Compiled-node id; matches Step.node_id
expression: string; // Source text of this node
children: ExpressionNode[];
}
interface Step {
step_id: number;
node_id: number;
context: any; // Data context the node saw
result: any | null; // The node's result; null when the step failed
error: string | null; // Failure message; null when the step succeeded
iteration_index?: number;
iteration_total?: number;
}
interface StructuredError {
type: string; // Stable tag: "Thrown", "InvalidOperator", "ParseError", ...
message: string;
operator?: string; // Innermost failing operator
node_ids?: number[]; // Breadcrumb from the failure site toward the root
// plus kind-specific extras: thrown, variable, index, length, ...
}
Things to know about the shape:
- Literals never record a step. Only operator nodes (including
var) appear insteps, so a rule with literal operands has fewer steps than it has JSON nodes. - Node ids are assigned at compile time, children first, so the root of the tree is the highest id.
id: 0with an emptyexpressionis only the placeholder returned when compilation itself fails. pointersplaces each node in the rule as written: every node id maps to the RFC 6901 JSON Pointer of the rule value it was compiled from ("/and/1",""for the root). It is absent when the rule does not compile.resultkeeps its object key order.
Failures do not throw: result is null, error carries the message, and structured_error the structured form. An unknown mode does throw (ParseError).
The
TracedResultJSON layout is the JavaScript-side wire shape, shared by every binding and described byschemas/trace.v1.jsonin the repository. On the Rust side it is produced from adatalogic_rs::TracedRun<String>(see the Rust API reference).
Example:
const trace = engine.evaluateWithTrace(
'{"and": [true, {"var": "x"}]}',
'{"x": false}'
);
const data = JSON.parse(trace);
console.log(data.result); // false
console.log(data.steps.length); // 2 (var lookup, then and; the literal true records no step)
console.log(data.expression_tree.id); // 4 (the root; the var node is id 3)
console.log(data.pointers['3']); // "/and/1"
// A failing rule reports the error inside the envelope instead of throwing
const failed = JSON.parse(engine.evaluateWithTrace('{"throw": "boom"}', '{}'));
console.log(failed.result); // null
console.log(failed.error); // 'Thrown: {"type":"boom"} (in operator: throw)'
console.log(failed.structured_error.type); // "Thrown"
const strictEngine = new Engine({ config: { preset: 'strict' } });
const run = JSON.parse(strictEngine.evaluateWithTrace('{"+": [null, 1]}', '{}'));
run.structured_error.thrown; // { type: "NaN" }
session(): Session
Open a hot-loop Session bound to this engine.
operators(): string
Every built-in operator this engine evaluates (following its families), as a JSON array in the schema of the operator catalogue:
| Field | Meaning |
|---|---|
name, aliases | Canonical name and the other keys that call it (val / ["var"]) |
family, feature | Operator family ("Core", "DateTime", …) and the Cargo feature that gates it (null for the core) |
min_args, max_args | Argument counts the operator reads (max_args: null is unbounded) |
reads_context | Whether it reads the data context |
effect | "pure", "clock" (now), "throws" or "catches" |
cost | What its work is proportional to: "node", "bytes", "per_item", "n_log_n", "quadratic", "elements" |
scoped_arg | Which argument runs once per element under a pushed frame (an index, "last", or null) |
JSON.parse(engine.operators()).find((op) => op.name === 'reduce');
// { name: "reduce", aliases: [], family: "Core", feature: null, min_args: 2, max_args: 3,
// reads_context: false, effect: "pure", cost: "per_item", scoped_arg: 1 }
truthy(value: string): boolean
Whether the JSON value is truthy under this engine’s configured truthiness (the rules if, and, or and session.evaluateTruthy apply). Under the default rules an empty object is falsy, like an empty array. Malformed JSON throws a ParseError.
engine.truthy('{}'); // false
engine.truthy('[0]'); // true
engine.truthy('"0"'); // true (a non-empty string)
customOperatorNames(): string[]
Names of the custom operators registered on this engine (order is not guaranteed). The module-level builtinOperatorNames() lists the built-ins.
engine.customOperatorNames(); // ["double"]
new Engine({}).customOperatorNames(); // []
free(): void
Release the engine’s WASM memory eagerly. A FinalizationRegistry also reclaims every class in this package when the JS object is garbage-collected, but that is best-effort; call free() on objects you create often (per render, per request). A freed object throws on use.
Rule
A rule compiled by engine.compile() (or compileTemplate, compileStrict, compileChecked). It keeps access to its engine’s custom operators and config.
evaluate(data: string): string- evaluate against a JSON data stringevaluateData(handle: DataHandle): string- evaluate against aDataHandleevaluateMetered(data: string, budget?: number): string- evaluate under an operation budget, returning{"result": ..., "ops": N}as forevalMeteredfacts(): string- what the rule reads and calls, as JSON (below)free(): void
const rule = engine.compile('{">=": [{"var": "user.age"}, 18]}');
rule.evaluate('{"user": {"age": 21}}'); // "true"
facts() describes the compiled rule, after the optimizer, without running it:
JSON.parse(engine.compile('{"+": [{"var": "a.b"}, {"var": "c"}]}').facts());
// { reads: [["a", "b"], ["c"]], computed_reads: false, reads_complete: true,
// reads_data: true, operators: ["+", "val"], custom_operators: [], deterministic: true }
| Field | Meaning |
|---|---|
reads | Data paths read from the root, each as its segments, sorted, with any path a shorter one covers dropped. Reads inside iterator bodies and try catch arms resolve against the element or the error and are left out |
computed_reads | A path is only known at runtime ({"var": {"cat": [...]}}) |
reads_complete | reads is everything the rule can read: no computed path, no custom operator |
reads_data | The result depends on the data at all |
operators | Canonical built-in names the rule uses (val for var), matching engine.operators() |
custom_operators | Custom operator names the rule calls |
deterministic | false for now and any custom operator |
A branch the optimizer folded away is neither read nor listed.
Session
The hot-loop tier. A session owns one bump arena and resets it at the start of each evaluation, so a tight loop reuses the same memory instead of allocating a fresh arena per call (which is what rule.evaluate() does). The session returns results as owned strings, so they stay valid across later calls.
const engine = new Engine({});
const rule = engine.compile('{">=": [{"var": "user.age"}, 18]}');
const session = engine.session();
const handle = new DataHandle('{"user": {"age": 34}}');
session.evaluate(rule, '{"user": {"age": 34}}'); // "true" (JSON string)
session.evaluateData(rule, handle); // "true" (JSON string, no parse per call)
session.evaluateBool(rule, handle); // true (real boolean)
session.evaluateTruthy(rule, handle); // true
Session methods
evaluate(rule: Rule, data: string): string- evaluate against a JSON data string, reusing the arenaevaluateData(rule: Rule, handle: DataHandle): string- the hot path: arena reuse and no per-call data workevaluateMetered(rule: Rule, data: string, budget?: number): string- metered evaluation with arena reuse, returning{"result": ..., "ops": N}evaluateBool(rule: Rule, handle: DataHandle): boolean- result must be a strict JSON boolean; anything else throws anErrornamedTypeMismatch(for example"result is not a boolean (got number)")evaluateInt(rule: Rule, handle: DataHandle): number- a whole JSON number a JS number holds exactly (|n| <= 2^53 - 1); otherwise throwsTypeMismatchevaluateFloat(rule: Rule, handle: DataHandle): number- accepts any JSON number; otherwise throwsTypeMismatchevaluateTruthy(rule: Rule, handle: DataHandle): boolean- collapses any result through the engine’s truthiness rules (the same coercionif/and/orapply); never type-mismatchesevaluateBatch(rule: Rule, handles: DataHandle[])- one rule against many handlesevaluateMany(rules: Rule[], handle: DataHandle)- many rules against one handle (the rule-set / feature-flag shape)reset(): void- reset the arena (optional; everyevaluate*call resets first)allocatedBytes(): number- bytes held by the arena’s chunksfree(): void
evaluateNumber(rule, handle) is the deprecated name of evaluateFloat.
The two batch methods return one Promise.allSettled-style plain object per item, in order. Item failures never fail the call:
type BatchOutcome =
| { status: 'fulfilled'; value: string } // result as a JSON string
| { status: 'rejected'; reason: { tag: string; message: string; operator?: string } };
const young = new DataHandle('{"user": {"age": 12}}');
session.evaluateBatch(rule, [handle, young]);
// [{ status: "fulfilled", value: "true" }, { status: "fulfilled", value: "false" }]
const age = engine.compile('{"var": "user.age"}');
session.evaluateMany([rule, age], handle);
// [{ status: "fulfilled", value: "true" }, { status: "fulfilled", value: "34" }]
session.evaluateMany([rule, 'nope'], handle)[1];
// { status: "rejected", reason: { tag: "InvalidArgument", message: "rules[1] is not a Rule" } }
Sessions are single-threaded like everything else in this package: use a session inside the Worker that created it.
DataHandle
An immutable, pre-parsed JSON document resident in WASM linear memory. Every string-taking method copies the data across the JS/WASM boundary and re-parses it on each call; a handle pays that once, so it is the right tool when you evaluate the same payload more than once (rule sets, bulk scoring).
new DataHandle(json: string)
Throws: An Error named ParseError on malformed JSON.
allocatedBytes(getter) - bytes held by the handle’s backing arena (input copy + parsed tree)free(): void- release the resident copy after the last evaluation
Evaluation never consumes a handle, and handles are independent of any engine: one handle can feed rules and sessions of different engines, as long as everything lives in the same module instance.
const handle = new DataHandle('{"user": {"age": 34}}');
rule.evaluateData(handle); // "true"
session.evaluateData(rule, handle); // "true"
handle.free();
Engine configuration
new Engine({ config }) accepts an optional evaluation config, as a JSON string or a plain object. All keys are optional; unknown keys or values throw a ConfigurationError. See Configuration for what each option means.
| Key | Value |
|---|---|
preset | "default" | "safe_arithmetic" | "strict" |
arithmetic_nan_handling | "throw_error" | "ignore_value" | "coerce_to_zero" | "return_null" |
division_by_zero | "return_saturated" | "throw_error" | "return_null" | "return_infinity" |
loose_equality_errors | boolean |
missing_var | "null" (default: a missing variable reads as null) | "error" (throws VariableNotFound) |
truthy_evaluator | "javascript" | "python" | "strict_boolean" |
numeric_coercion | object of booleans: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
max_recursion_depth | integer >= 1 |
ops_budget | integer >= 1, or null for unbounded (caps the work one evaluation may do; crossing it throws BudgetExceeded) |
preset applies first; the remaining keys override it individually.
// The default engine evaluates {"+": [null, 1]} to "1"; strict throws instead.
const engine = new Engine({ config: { preset: 'strict' } });
engine.evalStr('{"+": [null, 1]}', '{}');
// throws Error { name: "Thrown", thrown: { type: "NaN" }, operator: "+", ... }
new Engine({ config: { presett: 'strict' } });
// throws Error { name: "ConfigurationError", message: 'Configuration error: unknown config key "presett"' }
// A typo in a data path fails instead of reading null
const checked = new Engine({ config: { missing_var: 'error' } });
checked.evalStr('{"var": "user.nmae"}', '{"user": {"name": "Ana"}}');
// throws Error { name: "VariableNotFound", variable: "user.nmae", operator: "var", ... }
checked.evalStr('{"var": ["user.nmae", "anonymous"]}', '{"user": {"name": "Ana"}}'); // '"anonymous"'
Under missing_var: "error", a var / val default, a present null, missing, missing_some and exists are not misses, and try catches the error.
Type Definitions
Input/Output Types
Rules, data and most results cross the boundary as JSON strings. Parse results for use:
// Input: JSON strings
const logic: string = JSON.stringify({ "==": [1, 1] });
const data: string = JSON.stringify({ x: 42 });
// Output: a JSON string
const result: string = engine.evalStr(logic, data);
const parsed: boolean = JSON.parse(result); // true
The exceptions return JS values: builtinOperatorNames() and customOperatorNames() (string arrays), truthy() and the typed session methods (booleans and numbers), the batch methods (arrays of outcome objects, whose value is still a JSON string), and DataHandle.allocatedBytes / session.allocatedBytes() (numbers).
Templating Mode
In templating mode:
- Unknown object keys become output fields
- Only recognized operators are evaluated
- Useful for JSON templating
const engine = new Engine();
// Without templating - "result" is treated as an operator name
engine.evalStr('{"result": {"var": "x"}}', '{"x": 1}');
// throws Error { name: "InvalidOperator", operator: "result", ... }
// With templating - "result" becomes an output field
engine.compileTemplate('{"result": {"var": "x"}}').evaluate('{"x": 1}');
// '{"result":1}'
new Engine({ templating: true }).evalStr('{"result": {"var": "x"}}', '{"x": 1}');
// '{"result":1}'
Error Handling
Every function and method throws a real Error object (e instanceof Error is true), with the structured fields attached as own properties:
| Property | Contents |
|---|---|
name | Stable error-kind tag: "ParseError", "InvalidOperator", "InvalidArguments", "VariableNotFound", "Thrown", "IndexOutOfBounds", "TypeError", "ConfigurationError", "BudgetExceeded", "Custom", … plus "TypeMismatch" from the typed session methods and "CompileError" from compileChecked |
message | Human-readable message, including (in operator: ...) when the failing operator is known |
type | Same tag as name (mirrors the wire JSON) |
operator | Innermost failing operator, custom operators included (runtime errors only) |
node_ids | Breadcrumb of compiled-node ids from the failure site toward the root (runtime errors only) |
| variant extras | Kind-specific fields: thrown (Thrown, as a parsed JS value), variable (VariableNotFound), index / length (IndexOutOfBounds), budget / spent (BudgetExceeded), diagnostics (CompileError), stage (boundary input errors, for example "parse-data") |
detailJson | The structured error as a JSON string (what 5.0.0 used as the rejection value) |
try {
engine.evalStr('{"invalid json', '{}');
} catch (e) {
e instanceof Error; // true
e.name; // "ParseError"
e.message; // "Parse error: json parse error at byte 14: unexpected end of input"
}
try {
engine.evalStr('{"throw": "limit_exceeded"}', '{}');
} catch (e) {
e.name; // "Thrown"
e.thrown; // { type: "limit_exceeded" }
e.operator; // "throw"
e.node_ids; // [2]
}
Common error kinds:
ParseError: invalid JSON in the logic, data, value or options, or an invalid budget or modeCompileError:compileCheckedfound an error diagnostic;diagnosticslists themInvalidOperator: an unknown operator name (raised at evaluation, not at compile), or a multi-key object when templating is offThrown: an explicitthrow, or arithmetic that producedNaN({"+": ["abc", 1]})InvalidArguments: wrong argument shape for an operator (for example an unknowndate_diffunit)VariableNotFound: a missing variable on an engine configured withmissing_var: "error"ConfigurationError: an unknown config key or value, an unknown family, or a refused custom operator nameBudgetExceeded: the evaluation charged past its operation budgetTypeMismatch:session.evaluateBool/evaluateInt/evaluateFloatgot a result of another type
Missing data is not an error by default: {"var": "missing"} evaluates to null unless the engine sets missing_var: "error".
Deprecated APIs
These exports still work in 5.x and are removed in 6.0. The package’s TypeScript declarations mark them @deprecated.
| Deprecated | Use instead |
|---|---|
evaluate(logic, data, templating) | new Engine({ templating }).evalStr(logic, data); build the engine once |
evaluateWithTrace(logic, data, templating) | engine.evaluateWithTrace(logic, data, mode?), which also applies the engine’s config and custom operators |
new CompiledRule(logic, templating, config?, templateKeyEscape?) | new Engine({ templating, config, templateKeyEscape }).compile(logic) |
session.evaluateNumber(rule, handle) | session.evaluateFloat(rule, handle) |
evaluate()
function evaluate(logic: string, data: string, templating: boolean): string;
Builds a default engine (with templating as given) on every call and evaluates logic against data, returning a JSON string.
evaluateWithTrace()
function evaluateWithTrace(logic: string, data: string, templating: boolean): string;
Returns the same TracedResult envelope as engine.evaluateWithTrace(), under default engine settings.
CompiledRule
new CompiledRule(logic: string, templating: boolean, config?: string | object, templateKeyEscape?: string)
A compiled rule with its own internal engine, so it cannot use custom operators. Methods: evaluate(data: string): string, evaluateData(handle: DataHandle): string, free(): void. session.evaluateMany does not accept a CompiledRule; it takes Rules from engine.compile().
Performance Tips
-
Compile once for repeated evaluation:
// Slow: recompiles each time for (const user of users) { engine.evalStr(logic, JSON.stringify(user)); } // Fast: compile once const rule = engine.compile(logic); for (const user of users) { rule.evaluate(JSON.stringify(user)); } -
Initialize once at startup (browser/bundler), and build one engine:
// Application entry point await init(); export const engine = new Engine(); // Now use engine.compile / engine.evalStr anywhere -
Reuse
Ruleinstances, and free the ones you stop using:// Store compiled rules const rules = { isAdult: engine.compile('{">=": [{"var": "age"}, 18]}'), isPremium: engine.compile('{"==": [{"var": "tier"}, "premium"]}'), }; // Rules created per request or per render should be released rule.free(); -
Parse a payload once when it feeds several evaluations: a
DataHandleplus aSessionskips the per-call copy and parse; on kilobyte payloads that is most of the round trip.
Next Steps
- Quick Start - Core evaluation and compilation patterns
- Framework Integration - React, Vue, Next.js, and Web Worker recipes
- Node.js Native Binding - Native N-API package for Node.js servers
- React Visual Debugger - Embeddable visual rule editor and debugger
- Troubleshooting - Solutions for common bundler and initialization issues
Framework Integration
Integration recipes for common JavaScript frameworks and build tools.
React
Basic Setup
import { useEffect, useState } from 'react';
import init, { Engine } from '@goplasmatic/datalogic-wasm';
function App() {
const [engine, setEngine] = useState<Engine | null>(null);
useEffect(() => {
init().then(() => setEngine(new Engine()));
}, []);
if (!engine) return <div>Loading...</div>;
return <RuleEvaluator engine={engine} />;
}
function RuleEvaluator({ engine }: { engine: Engine }) {
const result = engine.evalStr('{"==": [1, 1]}', '{}');
return <div>Result: {result}</div>;
}
Custom Hook
Create a reusable hook for JSONLogic evaluation:
// useJsonLogic.ts
import { useEffect, useRef, useState } from 'react';
import init, { Engine, Rule } from '@goplasmatic/datalogic-wasm';
// Load the module and build one engine, once, for every component
let enginePromise: Promise<Engine> | null = null;
export function getEngine() {
if (!enginePromise) {
enginePromise = init().then(() => new Engine());
}
return enginePromise;
}
// Inputs are keyed by their JSON text, so callers may pass fresh object
// literals on every render: the rule is recompiled only when its text
// changes, and an object result can never re-trigger the effect.
export function useJsonLogic(logic: object, data: unknown) {
const [engine, setEngine] = useState<Engine | null>(null);
const [result, setResult] = useState<unknown>(null);
const [error, setError] = useState<string | null>(null);
const ruleRef = useRef<{ json: string; rule: Rule } | null>(null);
const logicJson = JSON.stringify(logic);
const dataJson = JSON.stringify(data);
useEffect(() => {
getEngine().then(setEngine);
}, []);
useEffect(() => {
if (!engine) return;
try {
if (!ruleRef.current || ruleRef.current.json !== logicJson) {
// Release the previous rule's WASM memory before compiling the next one
const previous = ruleRef.current;
ruleRef.current = null;
previous?.rule.free();
ruleRef.current = { json: logicJson, rule: engine.compile(logicJson) };
}
setResult(JSON.parse(ruleRef.current.rule.evaluate(dataJson)));
setError(null);
} catch (e) {
setError(String(e));
}
}, [logicJson, dataJson, engine]);
// Free the compiled rule on unmount
useEffect(() => () => {
ruleRef.current?.rule.free();
ruleRef.current = null;
}, []);
return { result, error, ready: engine !== null };
}
Two details matter in this hook:
- Key effects on serialized JSON strings, not object references: an inline rule literal creates a new object reference on every render, and
JSON.parsecreates a new result reference on every evaluation. KeyinguseEffectonlogicJsonanddataJsonensures the rule only recompiles when its JSON text changes and prevents infinite render loops (“Maximum update depth exceeded”). - Free compiled rules explicitly: every
Ruleholds WASM linear memory. The hook callsfree()whenever it replaces a compiled rule and again on component unmount, rather than waiting for garbage collection. Because theEngineis shared across the page, the hook leaves it alive.
Usage:
function FeatureFlag({ feature, user }) {
// An inline literal is fine: the hook keys on the rule's JSON text
const rule = { "and": [
{ "in": [feature, { "var": "enabledFeatures" }] },
{ ">=": [{ "var": "accountAge" }, 30] }
]};
const { result, error, ready } = useJsonLogic(rule, user);
if (!ready) return null;
if (error) return <div>Error: {error}</div>;
return result ? <NewFeature /> : <LegacyFeature />;
}
With React Query
import { useQuery } from '@tanstack/react-query';
import { getEngine } from './useJsonLogic'; // the shared engine from the hook above
export function useCompiledRule(logic: object) {
return useQuery({
queryKey: ['compiled-rule', JSON.stringify(logic)],
queryFn: async () => (await getEngine()).compile(JSON.stringify(logic)),
staleTime: Infinity,
});
}
Vue
Composition API
<script setup lang="ts">
import { ref, shallowRef, onMounted, computed } from 'vue';
import init, { Engine } from '@goplasmatic/datalogic-wasm';
const engine = shallowRef<Engine | null>(null);
const ready = computed(() => engine.value !== null);
const data = ref({ age: 25 });
onMounted(async () => {
await init();
engine.value = new Engine();
});
const rule = computed(() => {
if (!engine.value) return null;
return engine.value.compile('{">=": [{"var": "age"}, 18]}');
});
const isAdult = computed(() => {
if (!rule.value) return null;
return JSON.parse(rule.value.evaluate(JSON.stringify(data.value)));
});
</script>
<template>
<div v-if="ready">
Is Adult: {{ isAdult }}
</div>
<div v-else>Loading...</div>
</template>
Composable
// useJsonLogic.ts
import { ref, onMounted, onUnmounted, watchEffect, Ref } from 'vue';
import init, { Engine, Rule } from '@goplasmatic/datalogic-wasm';
// One engine for the app, built after the module loads
let enginePromise: Promise<Engine> | null = null;
export function useJsonLogic(logic: Ref<object>, data: Ref<unknown>) {
const result = ref<unknown>(null);
const error = ref<string | null>(null);
const ready = ref(false);
let engine: Engine | null = null;
let compiled: { json: string; rule: Rule } | null = null;
onMounted(async () => {
if (!enginePromise) enginePromise = init().then(() => new Engine());
engine = await enginePromise;
ready.value = true;
});
watchEffect(() => {
if (!ready.value || !engine) return;
const logicJson = JSON.stringify(logic.value);
const dataJson = JSON.stringify(data.value);
try {
// Recompile only when the rule text changes, freeing the previous rule
if (!compiled || compiled.json !== logicJson) {
const previous = compiled;
compiled = null;
previous?.rule.free();
compiled = { json: logicJson, rule: engine.compile(logicJson) };
}
result.value = JSON.parse(compiled.rule.evaluate(dataJson));
error.value = null;
} catch (e) {
error.value = String(e);
}
});
onUnmounted(() => {
compiled?.rule.free();
compiled = null;
});
return { result, error, ready };
}
As with the React hook, the composable compares the rule’s JSON text so a data change does not recompile, and it frees each Rule it replaces (and the last one on unmount) instead of leaving WASM memory to the garbage collector.
Node.js
On Node servers, the native @goplasmatic/datalogic-node package is faster and takes rules and data as JS values; Integration: Express walks through a service built on it. The recipes below use the WASM package, for code that shares one artifact with a browser build.
Express Middleware
const express = require('express');
const { Engine } = require('@goplasmatic/datalogic-wasm');
const app = express();
app.use(express.json());
// One engine for the process; compile rules at startup
const engine = new Engine();
const rules = {
canAccess: engine.compile(JSON.stringify({
"and": [
{ "==": [{ "var": "role" }, "admin"] },
{ "var": "active" }
]
}))
};
// Middleware
function authorize(ruleName) {
return (req, res, next) => {
const rule = rules[ruleName];
if (!rule) return res.status(500).json({ error: 'Unknown rule' });
const result = JSON.parse(rule.evaluate(JSON.stringify(req.user)));
if (result) {
next();
} else {
res.status(403).json({ error: 'Forbidden' });
}
};
}
app.get('/admin', authorize('canAccess'), (req, res) => {
res.json({ message: 'Welcome, admin!' });
});
Rule Evaluation API
const { Engine } = require('@goplasmatic/datalogic-wasm');
const engine = new Engine();
app.post('/api/evaluate', (req, res) => {
const { logic, data, templating = false } = req.body;
let rule;
try {
// Choose the templating mode per request instead of per engine
const text = JSON.stringify(logic);
rule = templating ? engine.compileTemplate(text) : engine.compile(text);
res.json({ result: JSON.parse(rule.evaluate(JSON.stringify(data))) });
} catch (error) {
res.status(400).json({ error: String(error) });
} finally {
rule?.free();
}
});
Bundler Configuration
Vite
Vite needs no WASM-specific configuration:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
// No special configuration needed
});
Webpack 5
Enable async WASM:
// webpack.config.js
module.exports = {
experiments: {
asyncWebAssembly: true,
},
};
Next.js
// next.config.js
module.exports = {
webpack: (config) => {
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
return config;
},
};
For App Router, create a client component:
'use client';
import { useEffect, useState } from 'react';
import init, { Engine } from '@goplasmatic/datalogic-wasm';
let enginePromise: Promise<Engine> | null = null;
const getEngine = () => (enginePromise ??= init().then(() => new Engine()));
export function JsonLogicEvaluator({ logic, data }) {
const [result, setResult] = useState(null);
useEffect(() => {
getEngine().then((engine) => {
const res = engine.evalStr(JSON.stringify(logic), JSON.stringify(data));
setResult(JSON.parse(res));
});
}, [logic, data]);
return <div>{JSON.stringify(result)}</div>;
}
Browser (No Build Tools)
For simple pages without bundlers:
<!DOCTYPE html>
<html>
<head>
<title>JSONLogic Demo</title>
</head>
<body>
<div id="result"></div>
<script type="module">
import init, { Engine } from 'https://unpkg.com/@goplasmatic/datalogic-wasm@latest/web/datalogic_wasm.js';
async function run() {
await init();
const engine = new Engine();
const logic = JSON.stringify({ ">=": [{ "var": "age" }, 18] });
const data = JSON.stringify({ age: 21 });
const result = JSON.parse(engine.evalStr(logic, data));
document.getElementById('result').textContent =
result ? 'Adult' : 'Minor';
}
run();
</script>
</body>
</html>
Worker Threads
Web Worker
// worker.js
import init, { Engine } from '@goplasmatic/datalogic-wasm';
let rule = null;
self.onmessage = async (e) => {
if (e.data.type === 'init') {
await init();
// Each Worker loads its own module, so it builds its own engine and rule
rule = new Engine().compile(e.data.logic);
self.postMessage({ type: 'ready' });
} else if (e.data.type === 'evaluate') {
const result = rule.evaluate(JSON.stringify(e.data.data));
self.postMessage({ type: 'result', result: JSON.parse(result) });
}
};
Node.js Worker Thread
const { Worker, isMainThread, parentPort } = require('worker_threads');
const { Engine } = require('@goplasmatic/datalogic-wasm');
if (isMainThread) {
const worker = new Worker(__filename);
worker.postMessage({ logic: '{"==": [1, 1]}', data: {} }); // logic is JSON text, data is an object
worker.on('message', (result) => console.log(result)); // true
} else {
const engine = new Engine(); // one per worker
parentPort.on('message', ({ logic, data }) => {
// logic is already JSON text: pass it through as is. Stringifying it
// again would compile the literal string '{"==": [1, 1]}' and return it.
const rule = engine.compile(logic);
const result = JSON.parse(rule.evaluate(JSON.stringify(data)));
rule.free();
parentPort.postMessage(result);
});
}
Next Steps
- API Reference - Full WASM class and method reference
- React Visual Debugger - Drop-in React visual rule builder and debugger component
- Node.js Native Binding and Integration: Express - High-throughput native Node.js server integration
- Starter Boilerplates - Minimal Express, FastAPI, and Axum service templates
- Troubleshooting - Bundler, SSR, and memory-management fixes
Installation
Install the pre-built datalogic-py package from PyPI:
# pip
pip install datalogic-py
# poetry
poetry add datalogic-py
# pipenv
pipenv install datalogic-py
Supported Python Versions
datalogic-py supports Python 3.10 and newer. It builds with pyo3 against the PEP 384 Stable ABI (abi3), so:
- One prebuilt wheel per platform works on every CPython release from 3.10 on; the package metadata lists 3.10 through 3.14.
- Installing the wheel needs no local C compiler or Rust toolchain.
PyPI carries wheels for Linux (manylinux and musllinux) on x86_64 and aarch64, macOS on x86_64 and arm64, and Windows on x86_64 and arm64. Every wheel ships type stubs and a py.typed marker, so mypy, pyright and IDE autocomplete see the whole API.
Importing in Python
The distribution name and the import name differ:
- PyPI Distribution name:
datalogic-py(with a hyphen) - Python import name:
datalogic_py(with an underscore, as Python import paths cannot contain hyphens)
import datalogic_py
print(datalogic_py.__version__) # "5.9.0"
Next Steps
- Quick Start: run your first one-shot and compiled evaluations in Python
- API & GIL Management: sessions, GIL release, data handles, and batch evaluation
- Configuration & Errors: engine presets, exception hierarchy, and type conversion
- Starter Boilerplates: copy-paste FastAPI pricing endpoint
Quick Start
Evaluate rules in Python with the datalogic-py binding.
Simple One-Shot Evaluation
Use apply for one-off evaluations:
from datalogic_py import apply
# Arithmetic
result = apply({"+": [1, 2, 3]}, {})
print(result) # 6
# Variable Access
result = apply(
{"var": "user.age"},
{"user": {"age": 25}}
)
print(result) # 25
Reusable Compiled Rules
For production loops, compile the rule once. The engine parses the rule a single time into a compiled node tree, so each evaluation skips parsing:
from datalogic_py import Engine
engine = Engine()
# 1. Compile once
rule = engine.compile({"if": [{">": [{"var": "score"}, 50]}, "pass", "fail"]})
# 2. Evaluate many times
for user in [{"score": 75}, {"score": 30}, {"score": 90}]:
print(rule.evaluate(user)) # prints "pass", "fail", "pass"
Catching Rule Mistakes Before They Run
compile accepts a rule that calls an operator the engine doesn’t have; the call fails when it runs. compile_checked refuses such a rule up front and lists every problem, each located by a JSON Pointer into the rule:
from datalogic_py import CompileError, Engine
engine = Engine()
try:
engine.compile_checked({"if": [True, {"vr": "x"}, "no"]})
except CompileError as e:
for d in e.diagnostics:
print(d["pointer"], d["message"]) # /if/1 unknown operator `vr`; did you mean `var`?
engine.check(rule) returns the same list without compiling. API & GIL Management covers the other introspection calls.
Parsing Performance: evaluate vs evaluate_str
rule.evaluate(dict_data)accepts a Pythondictorlistand walks the Python objects straight into the engine’s arena (it falls back topythonizeonly for unusual types such as subclasses and sets). In the boundary benchmark this is about 3 to 6 times faster than ajson.dumps→evaluate_str→json.loadsround trip.rule.evaluate_str(json_string)accepts a raw JSON string. If you already have a serialized JSON payload (e.g. read from a network socket or file), use this method to skip Python-to-Rust dictionary marshaling.
Next Steps
- Installation: wheel platforms and supported Python versions
- API & GIL Management: sessions, multi-threaded GIL release,
DataHandle, and batch evaluation - Configuration & Errors: engine configuration presets, the exception hierarchy, and type conversion
- Starter Boilerplates: build a FastAPI service with compiled rules
API & GIL Management
datalogic-py gives you a session context manager for arena reuse, and it releases the Global Interpreter Lock (GIL) while the Rust engine works, so threads evaluate in parallel.
Session Lifecycle (Context Manager)
For tight loops, use the session() context manager. The session keeps one memory arena and resets it at the start of each evaluate call.
from datalogic_py import Engine
engine = Engine()
rule = engine.compile({"+": [{"var": "x"}, 1]})
data_items = [{"x": 1}, {"x": 2}, {"x": 3}]
with engine.session() as session:
for item in data_items:
# Reuses the same internal memory buffer, avoiding allocations
result = session.evaluate(rule, item)
print(result)
Global Interpreter Lock (GIL) Release
The binding releases the GIL while the engine evaluates, compiles a rule, runs check, or parses the JSON text you pass to DataHandle. It holds the GIL while it converts Python values to and from the engine’s arena, and while a custom-operator callback runs.
- Parallel execution: If you run
rule.evaluateinside aThreadPoolExecutoror athreading.Thread, the evaluations run concurrently on separate CPU cores inside the Rust engine. - Sharing: Share one
Engineand the compiledRuleobjects across all threads. KeepSessionobjects thread-local (one per thread): only the thread that opened a session may call it.
import concurrent.futures
from datalogic_py import Engine
engine = Engine()
rule = engine.compile({">=": [{"var": "age"}, 18]})
users = [{"age": 20}, {"age": 15}, {"age": 32}, {"age": 12}]
# The engine runs these evaluations on several OS threads at once
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(rule.evaluate, users))
print(results) # [True, False, True, False]
Data Handles, Typed Results, and Batch Evaluation
The session tier has three faster entry points on top of evaluate,
all taking a pre-parsed DataHandle instead of a dict or JSON string:
| Tier | Entry point | Use when |
|---|---|---|
| Data handle | DataHandle(json) then rule.evaluate_data(data) / session.evaluate_data(rule, data) | You evaluate the same payload many times: parse once, zero parse work per call |
| Typed | session.evaluate_bool/int/float/truthy(rule, data) | Predicates and scalar results, no result conversion on the way out |
| Batch | session.evaluate_batch(rule, datas) / session.evaluate_many(rules, data) | Many evaluations per native call, with per-item errors |
from datalogic_py import BatchItemError, DataHandle, Engine
engine = Engine()
rule = engine.compile({">=": [{"var": "age"}, 18]})
data = DataHandle('{"age": 25}') # immutable, thread-safe, engine-independent
rule.evaluate_data(data) # True
with engine.session() as session:
session.evaluate_bool(rule, data) # True (strict JSON boolean)
for r in session.evaluate_batch(rule, [data, DataHandle('{"age": 12}')]):
if isinstance(r, BatchItemError): # per-item failure, never raised
print(r.tag, r.message)
else:
print(r) # "true", then "false" (JSON strings)
evaluate_bool, evaluate_int, and evaluate_float raise
EvaluateError with .error_type == "TypeMismatch" when the result has
the wrong type (a datetime or duration result counts as a string);
evaluate_truthy coerces any result through the engine’s truthiness
rules and never mismatches. Rule.evaluate_data_str and
Session.evaluate_data_str return the result as a JSON string.
Checking and Describing Rules
These Engine and Rule methods inspect rules without evaluating them (Rule Analysis covers the diagnostics and facts in depth):
| Call | Returns |
|---|---|
engine.check(rule, mode=None) | Every problem the engine can see, as {code, severity, message, pointer, operator} dicts; mode is "engine", "strict" or "template" |
engine.compile_checked(rule) | A Rule, or raises CompileError (.diagnostics) when check finds an error |
engine.compile_template(rule) / engine.compile_strict(rule) | A Rule compiled in templating mode, or outside it, whatever the engine’s mode |
rule.facts() | What the rule reads (reads, each path as segments), which operators it calls, and whether it is deterministic |
engine.operators() | One dict per built-in operator the engine has: name, aliases, family, min_args, max_args, … |
engine.truthy(value) | Whether a value is truthy under the engine’s configured rules; a str is JSON text |
from datalogic_py import Engine
engine = Engine()
rule = engine.compile({"+": [{"var": "a.b"}, {"var": "c"}]})
rule.facts()["reads"] # [["a", "b"], ["c"]]
engine.truthy({}) # False (an empty dict is falsy, like [])
Two more Engine features: Engine(custom_operators={"name": fn})
registers host-language operators (each callable receives the
pre-evaluated arguments as a JSON-array string and returns a JSON
string), and engine.evaluate_with_trace(logic_json, data_json)
returns the step-by-step trace envelope the React debugger consumes.
datalogic_py.__version__ reports the installed version. Full
reference: Python README.
Error Handling
Every exception the binding raises descends from DataLogicError and
carries .error_type, .operator (the innermost failing operator),
.node_ids and .path. ParseError covers malformed JSON and
unsupported Python types, EvaluateError covers evaluation and
configuration failures, and CompileError covers rules
compile_checked refuses. Configuration & Errors
lists the tags and attributes.
from datalogic_py import Engine, EvaluateError
engine = Engine()
try:
engine.eval({"+": ["x", 1]}, {}) # adding a non-numeric string raises
except EvaluateError as e:
print(f"Error: {e.error_type}") # "Thrown" (NaN under the default config)
print(f"Failed at: {e.operator}") # "+"
print(f"Path: {e.path}") # list of step dicts
Next Steps
- Configuration & Errors: engine presets, missing-variable handling, and exception details
- Quick Start: basic one-shot and compiled evaluation
- Thread Safety: cross-language concurrency rules
- Operation Budget: cap untrusted rule execution with
ops_budgetandevaluate_metered
Configuration & Errors
Tune evaluation semantics with the config= keyword argument and handle
failures through the binding’s exception hierarchy.
Engine Configuration
Engine(...) accepts a keyword-only config= argument: a dict (or a
JSON string) with an optional "preset" key plus per-field overrides.
The preset applies first; the remaining keys override individual fields
on top of it. Unknown keys or values raise EvaluateError with
error_type == "ConfigurationError".
| Key | Values |
|---|---|
preset | "default", "safe_arithmetic", "strict" |
arithmetic_nan_handling | "throw_error", "ignore_value", "coerce_to_zero", "return_null" |
division_by_zero | "return_saturated", "throw_error", "return_null", "return_infinity" |
loose_equality_errors | bool |
missing_var | "null" (default: a missing variable reads as None), "error" (raises VariableNotFound) |
truthy_evaluator | "javascript", "python", "strict_boolean" |
numeric_coercion | object of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
max_recursion_depth | integer >= 1 |
ops_budget | integer >= 1, or None / null for unbounded (caps the work one evaluation may do; crossing it raises BudgetExceeded) |
The presets: "default" is JSONLogic-compatible behavior;
"safe_arithmetic" skips non-numeric operands and returns None on
float division by zero (integer/integer division by zero always
raises, whatever division_by_zero says); "strict" errors on any
type mismatch and disables lenient numeric coercion. See
Division by Zero for
the full table.
Example: Strict Preset with One Override
from datalogic_py import Engine, EvaluateError
# Start from the strict preset, then relax division by zero.
engine = Engine(config={
"preset": "strict",
"division_by_zero": "return_null",
})
engine.eval({"/": [1.5, 0]}, {}) # None (the override wins)
try:
engine.eval({"+": ["abc", 2]}, {}) # strict rejects non-numeric strings
except EvaluateError as e:
print(e.error_type) # "Thrown"
Two details: division_by_zero governs the float path only, so
{"/": [1, 0]} (integer / integer) raises under every setting; and the
strict preset rejects non-numeric strings, None, and "" in
arithmetic, while it still coerces numeric strings such as "1"
({"+": ["1", 2]} returns 3).
A JSON string works anywhere the dict does:
Engine(config='{"preset": "safe_arithmetic"}'). Every binding parses
this JSON schema with the same core code, so a config that works here
works in every other binding. Full semantics of each knob, with
behavior tables, are in
Configuration.
Missing Variables
By default a var or val read that finds nothing evaluates to None,
as JSONLogic specifies. "missing_var": "error" raises VariableNotFound instead,
so a typo in a path fails where it happens. A default
({"var": ["x", 0]}), a present null, missing, missing_some and
exists are not misses, and try catches the error:
from datalogic_py import Engine, EvaluateError
engine = Engine(config={"missing_var": "error"})
try:
engine.eval({"var": "user.nmae"}, {"user": {"name": "Ada"}})
except EvaluateError as e:
print(e.error_type) # "VariableNotFound"
engine.eval({"var": ["user.nmae", "anon"]}, {"user": {"name": "Ada"}}) # "anon"
Other Engine Options
| Argument | Effect |
|---|---|
templating=True | Compile multi-key objects as output templates; compile_template / compile_strict choose the mode per compile |
template_key_escape="$" | A template key starting with this character is a literal output field ({"$type": ...} emits type) |
families=["ExtString", ...] | Keep the engine to the JSONLogic core plus these operator families (the family of each engine.operators() row); the others compile as unknown operators |
strict_operator_names=True | Raise ConfigurationError for a custom operator named like a built-in instead of registering one that never runs |
Error Handling
All exceptions raised by the binding descend from DataLogicError:
| Exception | When |
|---|---|
DataLogicError | Base class; catch this for “anything from datalogic” |
ParseError | Malformed rule or data JSON, or an unsupported Python type in the dict path |
EvaluateError | Everything else the engine reports: runtime operator failures, unknown operators ("InvalidOperator"), invalid configuration ("ConfigurationError") |
CompileError | compile_checked refused the rule; .diagnostics lists every problem as {code, severity, message, pointer, operator} dicts |
Every exception carries these attributes:
.error_type: the engine’s stable error tag, e.g."Thrown","TypeError","InvalidArguments","InvalidOperator","VariableNotFound","BudgetExceeded", or"CompileError". The binding adds"TypeMismatch"(a typed session evaluation whose result has the wrong type) and"InvalidArgument"(for example a rule compiled by a different engine passed to a session)..operator: the innermost failing operator name ("+","var", …), orNone..node_ids: a leaf-to-root breadcrumb of compiled-node ids..path: a root-to-leaf list of step dicts, each withnode_id,operator,arg_index, andjson_pointer;Nonewhen no compiled rule was available to resolve it.
A BudgetExceeded error also carries .budget and .spent.
Parse Failures vs. Evaluate Failures
from datalogic_py import Engine, ParseError, EvaluateError
engine = Engine()
try:
engine.compile('{"var": ') # truncated JSON
except ParseError as e:
print(f"bad rule: {e}")
rule = engine.compile({"+": [{"var": "x"}, 1]})
try:
rule.evaluate({"x": "not a number"})
except EvaluateError as e:
print(e.error_type) # "Thrown" (NaN under the default config)
print(e.operator) # "+"
print(e.path) # [{"node_id": ..., "operator": "+", ...}]
A rule that executes the throw operator raises EvaluateError with
.error_type == "Thrown"; the message carries the thrown payload as
JSON ({"throw": "boom"} gives Thrown: {"type":"boom"}).
Type Conversion
The dict-input path (apply, Engine.eval, Rule.evaluate) walks
Python objects straight into the engine’s arena representation; it
falls back to pythonize only
for unusual shapes (subclasses, sets, mappings, out-of-range ints),
with identical results either way.
Supported: dict, list, tuple, str, int, float, bool,
None. The binding converts tuples and sets (set, frozenset) to
JSON arrays (set order is unspecified).
Non-finite floats: float('nan') and float('inf') have no JSON
encoding, so they become null on input and come back as None in
results. The same applies to results the engine produces: with
division_by_zero = "return_infinity", engine.eval({"/": [1.5, 0]}, {})
returns None, indistinguishable from "return_null", and the JSON
string entry points (eval_str, evaluate_str) serialize it as
null too.
Not supported (these raise ParseError):
datetime.datetime,datetime.date: convert to an ISO string at the Python edgedecimal.Decimal: convert tofloatorstrbytes,bytearray
For payloads with exotic types, use rule.evaluate_str(json_text) and
bring your own JSON encoder (e.g. json.dumps(payload, default=str)).
Next Steps
- API & GIL Management: sessions, GIL release,
DataHandle, and batch evaluation - Quick Start: basic evaluation patterns
- Starter Boilerplates: FastAPI service example
- Configuration and Rule Analysis: full engine configuration and static analysis reference
Installation & cgo Setup
The Go binding datalogic-go links the Rust core into your Go program statically through cgo.
Go Module Path
Add the Go module dependency. Go Modules requires the /v5 major-version suffix for versions 2 and above:
go get github.com/GoPlasmatic/datalogic-rs/bindings/go/v5@v5.9.0
Import it in your Go code:
import datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
Binary Staging
datalogic-go tags ship prebuilt static libraries for the following targets:
| OS | Architecture | Subdirectory |
|---|---|---|
| Linux | amd64 | linux_amd64/ |
| Linux | arm64 | linux_arm64/ |
| macOS | amd64 (Intel) | darwin_amd64/ |
| macOS | arm64 (Apple Silicon) | darwin_arm64/ |
| Windows | amd64 | windows_amd64/ |
| Windows | arm64 | windows_arm64/ |
cgo build tags select the matching static library (libdatalogic_c.a) at build time. Each library is built without debuginfo and with fat LTO (about 13.5 MB on darwin/arm64).
Requirements
- Go 1.25 or newer (the module’s
go.moddirective; older toolchains withGOTOOLCHAIN=autodownload it on demand). - A C compiler (e.g.
gccorclang/ Xcode command line tools) to link the static library duringgo build. - No Rust toolchain: the static library already contains the compiled Rust engine. You need Rust only to build the binding from a source checkout (
make buildinbindings/go).
Next Steps
- Quick Start: one-shot and compiled rule evaluation in Go
- Concurrency & Sessions: goroutine safety,
Sessionarena reuse,DataHandle, and batch evaluation - Configuration & Errors:
EngineBuilderoptions and the*datalogic.Errortype - C ABI: the underlying C FFI contract that
datalogic-golinks against
Quick Start
Evaluate rules in Go with the datalogic-go package.
One-Shot Evaluation
For quick calculations, use the package-level Apply function:
package main
import (
"fmt"
datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
)
func main() {
// Apply takes (ruleJSON, dataJSON) strings
result, err := datalogic.Apply(`{"+": [1, 2, 3]}`, `{}`)
if err != nil {
panic(err)
}
fmt.Println(result) // "6"
}
Reusable Compiled Rules
For performance-critical code paths, compile the rule once. The engine parses the rule a single time into a compiled node tree, so repeated evaluations skip re-parsing.
Defer .Close() on engines and rules: a GC finalizer frees a handle you forget, but only when the collector runs, and the collector cannot see the native memory behind the handle.
package main
import (
"fmt"
datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
)
func main() {
// 1. Create an engine
engine := datalogic.NewEngine()
defer engine.Close() // Releases engine configuration memory
// 2. Compile once
rule, err := engine.Compile(`{"if": [{">": [{"var": "score"}, 50]}, "pass", "fail"]}`)
if err != nil {
panic(err)
}
defer rule.Close() // Releases compiled rule memory
// 3. Evaluate many times
result1, _ := rule.Evaluate(`{"score": 75}`)
result2, _ := rule.Evaluate(`{"score": 30}`)
fmt.Println(result1) // "pass"
fmt.Println(result2) // "fail"
}
Catching Rule Mistakes Before They Run
Compile accepts a rule that calls an operator the engine doesn’t have; the call fails when it runs. CompileChecked refuses such a rule up front with a *datalogic.Error of Type == "CompileError", whose DiagnosticsJSON lists every problem with a JSON Pointer into the rule. engine.Check(ruleJSON, datalogic.ModeEngine) returns the same list without compiling:
_, err := engine.CompileChecked(`{"if": [true, {"vr": "x"}, "no"]}`)
var dlErr *datalogic.Error
if errors.As(err, &dlErr) && dlErr.Type == "CompileError" {
fmt.Println(dlErr.DiagnosticsJSON)
// [{"code":"UnknownOperator","severity":"error","message":"unknown operator `vr`; did you mean `var`?","pointer":"/if/1","operator":"vr"}]
}
Next Steps
- Installation & cgo Setup: Go module setup and prebuilt static libraries
- Concurrency & Sessions: goroutine safety,
Sessionarena reuse,DataHandle, and batch evaluation - Configuration & Errors: engine configuration via
EngineBuilderand structured*datalogic.Errorinspection
Concurrency & Sessions
datalogic-go exposes the Rust core’s thread-safety rules as goroutine-safety rules.
Concurrency Model
Engine: Goroutine-safe (Send + Syncin Rust). Construct a singleEngineand share it across goroutines.Rule: Goroutine-safe. Compile a rule once, and callrule.Evaluate()from multiple goroutines concurrently.Session: Not goroutine-safe. A session owns a reusable memory arena for evaluation buffers. Use each one from a single goroutine at a time.DataHandle: Goroutine-safe and engine-independent. Parse a payload once withdatalogic.ParseData(), share it across goroutines (evaluation only reads it), andClose()it after the last use.TracedSession: Goroutine-safe. EveryEvaluate()uses a fresh internal arena, so one traced session can serve many goroutines.EngineBuilder: Not goroutine-safe. Build on one goroutine, then share the resultingEngine.
Close() on any handle is safe to call more than once and from several goroutines at once: exactly one call frees the native handle. Closing a handle while another goroutine is still using it is not supported. Rules, sessions and traced sessions keep working after their Engine is closed, custom operators included: the engine’s callbacks stay registered until the last handle derived from it is unreachable.
API Tiers
| Tier | Entry point | Use when |
|---|---|---|
| Compile once | engine.Compile(rule) then rule.Evaluate(data) / rule.EvaluateData(handle) | Same rule evaluated against many data inputs |
| Compile mode | engine.CompileTemplate(rule) / engine.CompileStrict(rule) | One rule in a templating mode other than the engine’s |
| Checked | engine.Check(rule, mode) / engine.CompileChecked(rule) | Every problem in a rule before it runs, with JSON Pointers (Rule Analysis) |
| Introspection | rule.Facts() / engine.Operators() / engine.Truthy(value) | What a rule reads and calls, the operator catalogue, the engine’s truthiness |
| Session | engine.Session() then session.Evaluate(rule, data) / session.EvaluateData(rule, handle) | Hot loops: arena reuse per goroutine |
| Metered | session.EvaluateMetered(rule, data, budget) | The operations an evaluation charges, optionally capped (budget 0 is the engine’s own) |
| Data handle | datalogic.ParseData(json) | Same payload evaluated many times: parse once, zero parse work per call |
| Typed | session.EvaluateBool/Int64/Float64/Truthy(rule, handle) | Predicates and scalar results, no JSON decode on the way out (TypeMismatch error on the wrong type; EvaluateTruthy never mismatches) |
| Batch | session.EvaluateBatch(rule, handles) / session.EvaluateMany(rules, handle) | Many evaluations per native call, with per-item errors in each BatchResult.Err |
| Traced | engine.TracedSession() then ts.Evaluate(ruleJSON, dataJSON) / ts.EvaluateMode(ruleJSON, dataJSON, mode) | Step-level execution traces for debuggers and tooling |
Register custom operators with datalogic.NewEngineBuilder().AddOperator(name, fn) and an OperatorFunc (func(argsJSON string) (string, error)). The engine may call an operator from any goroutine that evaluates, so synchronise any state the callback shares. datalogic.Version() reports the linked engine version. Examples for every tier are in the Go README.
Reusing Arenas with Session
To avoid heap allocations in hot paths, create a Session per goroutine and defer its Close() call.
package main
import (
"fmt"
datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
)
func main() {
engine := datalogic.NewEngine()
defer engine.Close()
rule, _ := engine.Compile(`{"var": "user.name"}`)
defer rule.Close()
// 1. Create a session (owns a reusable memory arena)
session := engine.Session()
defer session.Close()
users := []string{
`{"user": {"name": "Alice"}}`,
`{"user": {"name": "Bob"}}`,
`{"user": {"name": "Charlie"}}`,
}
for _, user := range users {
// Reuses the session's internal arena allocation
result, _ := session.Evaluate(rule, user)
fmt.Println(result)
}
}
A session evaluates only rules compiled by its own engine; a rule from another engine fails with Type == "InvalidArgument".
Error Handling
Failed calls return a *datalogic.Error. Its Type is the engine’s error tag, Operator the innermost operator that failed, and PathJSON the path from the rule root to the failing node. Configuration & Errors lists every field and tag.
_, err := rule.Evaluate(`{}`)
if err != nil {
dErr, ok := err.(*datalogic.Error)
if ok {
fmt.Printf("Type: %s\n", dErr.Type)
fmt.Printf("Operator: %s\n", dErr.Operator)
fmt.Printf("Path: %s\n", dErr.PathJSON)
}
}
Next Steps
- Configuration & Errors: builder options, operator families, and error tags
- Quick Start: basic one-shot and compiled rule usage
- Thread Safety: cross-language concurrency patterns
- Operation Budget: cap untrusted evaluations with
ops_budgetandEvaluateMetered
Configuration & Errors
Configure evaluation semantics through the engine builder and handle
failures through the *datalogic.Error type.
Engine Configuration
datalogic.NewEngine() returns an engine with default configuration;
datalogic.NewTemplatingEngine() returns one with templating mode
enabled. Everything else goes through the builder:
datalogic.NewEngineBuilder(): create a fresh builder.b.SetConfigJSON(configJSON): set the evaluation configuration from a JSON object string; returns anerroron invalid config.b.Templating(on): toggle templating mode.b.TemplateKeyEscape(r): a template key starting with runerbecomes a literal output field (with'$',{"$type": ...}emitstype).b.Families(names...): keep the engine to the JSONLogic core plus the operator families named (see Operator Families).b.StrictOperatorNames(on): makeBuildrefuse a custom operator named like a built-in, instead of registering one that never runs. Call it beforeAddOperator.b.AddOperator(name, fn): register a custom operator.b.Build(): consume the builder and return the configured*Engine, or the first error a setter recorded.
SetConfigJSON parses the same JSON wire format every binding uses. All
keys are optional; "preset" picks the starting point and the remaining
keys override individual fields on top of it. Unknown keys, unknown enum
strings, and type mismatches return a *datalogic.Error with
Type == "ConfigurationError". Each call replaces the builder’s entire
evaluation config; templating and registered operators are unaffected.
| Key | Values |
|---|---|
preset | "default", "safe_arithmetic", "strict" |
arithmetic_nan_handling | "throw_error", "ignore_value", "coerce_to_zero", "return_null" |
division_by_zero | "return_saturated", "throw_error", "return_null", "return_infinity" |
loose_equality_errors | bool |
missing_var | "null" (default: a missing variable reads as null), "error" (fails with VariableNotFound) |
truthy_evaluator | "javascript", "python", "strict_boolean" |
numeric_coercion | object of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
max_recursion_depth | integer >= 1 |
ops_budget | integer >= 1, or null for unbounded (caps the work one evaluation may do; crossing it fails with BudgetExceeded) |
The presets: "default" is JSONLogic-compatible behavior;
"safe_arithmetic" skips non-numeric operands and returns null on
float division by zero (integer/integer division by zero always
errors, whatever division_by_zero says); "strict" errors on any
type mismatch and disables lenient numeric coercion. See
Division by Zero for
the full table.
Example: Strict Preset with One Override
b := datalogic.NewEngineBuilder()
if err := b.SetConfigJSON(`{"preset": "strict", "division_by_zero": "return_null"}`); err != nil {
log.Fatal(err) // typos in keys or values fail here
}
engine, err := b.Build()
if err != nil {
log.Fatal(err)
}
defer engine.Close()
out, _ := engine.Apply(`{"/": [1.5, 0]}`, `{}`) // "null" (the override wins)
_, err = engine.Apply(`{"+": [null, 1]}`, `{}`) // err != nil: strict rejects non-numeric operands
division_by_zero governs the float path only: {"/": [1, 0]}
(integer / integer) returns an error under every setting. The strict
preset rejects non-numeric strings, null, and "" in arithmetic;
it still coerces numeric strings such as "1", so
{"+": ["1", 2]} returns "3".
"missing_var": "error" makes a var or val read that finds nothing
fail with Type == "VariableNotFound" instead of reading as null. A
default ({"var": ["x", 0]}), a present null, missing,
missing_some and exists are not misses, and try catches the
error.
"ops_budget" caps the operations any evaluation on the engine may
charge; session.EvaluateMetered(rule, data, budget) sets a budget for
one call and returns the count (budget 0 means the engine’s own).
Operator Families
b.Families("ExtString", "DateTime") keeps the engine to the JSONLogic
core plus the families named, using the family names
engine.Operators() reports: ExtString, ExtArray, ExtObject,
ExtMath, ExtControl, ErrorHandling, DateTime, Tensor,
Flagd. By default an engine has every family. A family left out is
not there for that engine: its names compile as unknown operators,
which fail at evaluation with InvalidOperator (and as errors in
Check and CompileChecked), and a custom operator may take them. An
unknown family name fails Build with Type == "ConfigurationError".
Builders are not goroutine-safe: construct and Build() on one
goroutine, then share the resulting Engine freely (see
Concurrency & Sessions). A builder you drop without
calling Build is freed by its finalizer. Full semantics of each
knob, with behavior tables, are in
Configuration.
Error Handling
Every fallible operation returns a *datalogic.Error on failure:
| Field | Contents |
|---|---|
Message | Human-readable error string |
Type | The engine’s stable error tag; match on this for programmatic handling |
Operator | Innermost failing operator name ("+", "var", …); empty when the failure didn’t originate inside a named operator |
PathJSON | JSON array string of {node_id, operator, arg_index, json_pointer} steps from the rule root to the failing node; empty when no compiled rule was in scope |
NodeIDsJSON | The compiled-node breadcrumb, leaf to root, as a JSON array of ids |
DiagnosticsJSON | Type == "CompileError" only: every problem CompileChecked found, as {code, severity, message, pointer, operator} objects |
Error() formats as datalogic: <Type>: <Message>. The engine’s tags:
ParseError, Thrown, TypeError, InvalidArguments,
InvalidOperator, VariableNotFound, ArithmeticError, Custom,
FormatError, IndexOutOfBounds, InvalidContextLevel,
ConfigurationError, BudgetExceeded. Arithmetic NaN failures and
the rule-level throw operator both surface as "Thrown", with the
thrown payload serialized into Message. Four more tags come from the
binding layer rather than the engine: TypeMismatch (a typed
Session evaluation such as EvaluateBool whose result has the wrong
type), InvalidArgument (nil or closed handles, a rule compiled by a
different engine than the session’s), CompileError (a rule
CompileChecked refused), and InternalError (a panic caught at the
FFI boundary, or a batch item whose error JSON could not be decoded).
Compile Failures vs. Evaluate Failures
engine.Compile fails with Type == "ParseError" on malformed rule
JSON; Operator and PathJSON are empty because no compiled rule
exists yet. rule.Evaluate and session.Evaluate fail with runtime
tags and populate the full struct. Use errors.As to get the typed
error:
rule, err := engine.Compile(`{"+": [{"var": "x"}, 1]}`)
if err != nil {
var dlErr *datalogic.Error
if errors.As(err, &dlErr) && dlErr.Type == "ParseError" {
log.Fatalf("bad rule: %s", dlErr.Message)
}
}
defer rule.Close()
_, err = rule.Evaluate(`{"x": "not a number"}`)
var dlErr *datalogic.Error
if errors.As(err, &dlErr) {
fmt.Println(dlErr.Type) // "Thrown" (NaN under the default config)
fmt.Println(dlErr.Operator) // "+"
fmt.Println(dlErr.PathJSON) // [{"arg_index":null,"json_pointer":"","node_id":...,"operator":"+"}]
}
One exception to the pattern: TracedSession.Evaluate reports rule
parse and evaluation failures inside its returned JSON envelope (the
error and structured_error fields), with the Go error return
reserved for binding-level failures such as invalid handles.
Next Steps
- Concurrency & Sessions: goroutine safety,
Sessionarena reuse,DataHandle, and batch evaluation - Quick Start: basic evaluation patterns
- Configuration and Rule Analysis: full engine configuration and static analysis reference
- C ABI: the C FFI layer beneath
datalogic-go
Java / Kotlin (JVM)
The JVM binding io.github.goplasmatic:datalogic calls the shared C ABI through the Java FFM API (java.lang.foreign), with no JNA or JNI glue. It requires JDK 22 or newer (the FFM API is final since 22) and works from Java, Kotlin, and Scala.
Installation
Add the dependency to your project:
Maven (pom.xml)
<dependency>
<groupId>io.github.goplasmatic</groupId>
<artifactId>datalogic</artifactId>
<version>5.9.0</version>
</dependency>
Gradle (build.gradle)
implementation 'io.github.goplasmatic:datalogic:5.9.0'
On JDK 24 and newer the JVM prints a restricted-method warning the first time a library calls FFM, and a later JDK will deny the call by default. Grant native access when you start your application:
java --enable-native-access=ALL-UNNAMED ...
That flag covers classpath applications. The JAR declares Automatic-Module-Name: com.goplasmatic.datalogic, so on the module path you write requires com.goplasmatic.datalogic; and grant access with --enable-native-access=com.goplasmatic.datalogic.
The JAR bundles precompiled native libraries for Linux, macOS, and Windows on x86_64 and arm64. On first startup, the binding extracts the matching library into a per-user cache directory named by the library’s SHA-256:
- Linux:
$XDG_CACHE_HOME/datalogic/native/(defaults to~/.cache/datalogic/native/) - macOS:
~/Library/Caches/datalogic/native/ - Windows:
%LOCALAPPDATA%\datalogic\native\
Subsequent startups reuse the cached file, or fall back to a temporary directory if the cache is not writable. Set -Ddatalogic.library.path=<dir> to load a custom library you built yourself.
The Maven groupId is io.github.goplasmatic, but the Java package path is com.goplasmatic.datalogic.
Quick Start
One-Shot Evaluation
import com.goplasmatic.datalogic.Engine;
public class Main {
public static void main(String[] args) {
try (Engine engine = new Engine()) {
String result = engine.apply("{\"+\": [1, 2, 3]}", "{}");
System.out.println(result); // "6"
}
}
}
Reusable Compiled Rules
Compile rules that you evaluate repeatedly. Use Java’s try-with-resources statement to close native resources when you are done with them:
import com.goplasmatic.datalogic.Engine;
import com.goplasmatic.datalogic.Rule;
public class Main {
public static void main(String[] args) {
try (Engine engine = new Engine();
Rule rule = engine.compile("{\"if\": [{ \">\": [{\"var\": \"score\"}, 50] }, \"pass\", \"fail\"]}")) {
System.out.println(rule.evaluate("{\"score\": 75}")); // "pass"
System.out.println(rule.evaluate("{\"score\": 30}")); // "fail"
}
}
}
Arena Recycling with Session
To recycle memory allocations in hot loops, open a Session:
import com.goplasmatic.datalogic.Engine;
import com.goplasmatic.datalogic.Rule;
import com.goplasmatic.datalogic.Session;
public class Main {
public static void main(String[] args) {
try (Engine engine = new Engine();
Rule rule = engine.compile("{\"var\": \"user.name\"}")) {
try (Session session = engine.openSession()) {
for (String input : dataset) {
// Reuses the session's arena, reset at the start of each call
String name = session.evaluate(rule, input);
System.out.println(name);
}
}
}
}
}
Checking and Inspecting Rules
The engine can report problems in a rule before it runs, and describe a compiled rule:
import com.goplasmatic.datalogic.CompileMode;
import com.goplasmatic.datalogic.ParseException;
String diagnostics = engine.check("{\"if\": [true, {\"vr\": \"x\"}]}", CompileMode.ENGINE);
// [{"code":"UnknownOperator","severity":"error",
// "message":"unknown operator `vr`; did you mean `var`?","pointer":"/if/1","operator":"vr"}]
try (Rule rule = engine.compileChecked(ruleJson)) {
String facts = rule.facts(); // {"reads":[["user","age"]], "operators":[...], "deterministic":true, ...}
} catch (ParseException e) {
// e.errorType() is "CompileError"; e.diagnosticsJson() lists every problem
}
| Method | Returns |
|---|---|
engine.check(rule, mode) | Every problem the engine can see, as a JSON array of {code, severity, message, pointer, operator}; it does not throw for a bad rule |
engine.compileChecked(rule) | A Rule, or ParseException with error type "CompileError" if check finds an error |
engine.compileTemplate(rule) / compileStrict(rule) | A Rule compiled with templating on or off, whatever the engine’s own mode |
rule.facts() | The data paths the rule reads, the operators it calls, and whether it is deterministic, as JSON |
engine.operators() | The operator catalogue, in the schema of operators.json |
engine.truthy(valueJson) | Whether a JSON value is truthy under the engine’s configured truthiness |
Engine.builder() also takes withFamilies("ExtString", ...), which keeps the engine to the JSONLogic core plus the operator families you name, and withStrictOperatorNames(true), which makes addOperator throw for a name a built-in operator answers to. The engine config accepts "missing_var": "error", which turns a var read that finds nothing into a VariableNotFound error.
Concurrency
EngineandRuleinstances are thread-safe; you can share them globally.Sessioninstances are not thread-safe; keep each one local to a single thread.close()is idempotent and frees the native handle exactly once, even when several threads call it. A sharedjava.lang.ref.Cleanerfrees a handle you never close, including anEngineBuilderdropped beforebuild().- Rules and sessions keep their engine’s custom operators alive, so they keep working after
engine.close().
Going deeper
- Integration: Spring Boot: production service walkthrough with a database-backed rule cache,
@Beanlifecycle, and request-scoped sessions - C ABI internals: memory management & thread safety: the native-heap ownership rules every FFI binding shares
- Engine configuration semantics and Operation Budget
- Rule analysis: diagnostic codes, the
facts()fields, and the operator catalogue - Thread safety: cross-language concurrency patterns
- JVM binding README: full API surface, error types, and platform table
.NET / C# (P/Invoke)
The .NET binding Goplasmatic.Datalogic is a P/Invoke wrapper over the shared C ABI. It ships .NET 8.0 and .NET 10.0 builds and uses source-generated LibraryImport stubs, so it is NativeAOT-ready.
Installation
Add the NuGet package to your project:
dotnet add package Goplasmatic.Datalogic
or reference it in the project file:
<PackageReference Include="Goplasmatic.Datalogic" Version="5.9.0" />
The package ships precompiled shared libraries (.so, .dylib, .dll) under NuGet’s standard runtimes/ structure, and MSBuild picks the one for the target runtime identifier (RID) during publish. .NET 8 and 9 apps use the net8.0 build; .NET 10 and newer use net10.0.
Quick Start
One-Shot Evaluation
using Goplasmatic.Datalogic;
using var engine = new Engine();
var result = engine.Apply("""{"+": [1, 2, 3]}""", "{}");
Console.WriteLine(result); // "6"
Reusable Compiled Rules
Compile rules that you evaluate repeatedly. Use C#’s using var syntax or using blocks to dispose of native engine and rule memory:
using Goplasmatic.Datalogic;
using var engine = new Engine();
using var rule = engine.Compile("""{"if": [{ ">": [{"var": "score"}, 50] }, "pass", "fail"]}""");
Console.WriteLine(rule.Evaluate("""{"score": 75}""")); // "pass"
Console.WriteLine(rule.Evaluate("""{"score": 30}""")); // "fail"
Arena Recycling with Session
To recycle memory allocations in hot loops, open a Session:
using Goplasmatic.Datalogic;
using var engine = new Engine();
using var rule = engine.Compile("""{"var": "user.name"}""");
using var session = engine.OpenSession();
foreach (var input in dataset)
{
// Reuses the session's arena, reset at the start of each call
var name = session.Evaluate(rule, input);
Console.WriteLine(name);
}
Checking and Inspecting Rules
The engine can report problems in a rule before it runs, and describe a compiled rule:
var diagnostics = engine.Check("""{"if": [true, {"vr": "x"}]}""");
// [{"code":"UnknownOperator","severity":"error",
// "message":"unknown operator `vr`; did you mean `var`?","pointer":"/if/1","operator":"vr"}]
try
{
using var checkedRule = engine.CompileChecked(ruleJson);
var facts = checkedRule.Facts(); // {"reads":[["user","age"]], "operators":[...], "deterministic":true, ...}
}
catch (ParseException e) when (e.ErrorType == "CompileError")
{
Console.WriteLine(e.DiagnosticsJson); // every problem, each with a JSON Pointer into the rule
}
| Method | Returns |
|---|---|
engine.Check(rule, mode = CompileMode.Engine) | Every problem the engine can see, as a JSON array of {code, severity, message, pointer, operator}; it does not throw for a bad rule |
engine.CompileChecked(rule) | A Rule, or ParseException with ErrorType == "CompileError" if Check finds an error |
engine.CompileTemplate(rule) / CompileStrict(rule) | A Rule compiled with templating on or off, whatever the engine’s own mode |
rule.Facts() | The data paths the rule reads, the operators it calls, and whether it is deterministic, as JSON |
engine.Operators() | The operator catalogue, in the schema of operators.json |
engine.Truthy(valueJson) | Whether a JSON value is truthy under the engine’s configured truthiness |
Engine.Builder() also takes WithFamilies("ExtString", ...), which keeps the engine to the JSONLogic core plus the operator families you name, and WithStrictOperatorNames(true), which makes AddOperator throw for a name a built-in operator answers to. The engine config accepts "missing_var": "error", which turns a var read that finds nothing into a VariableNotFound error.
Concurrency
EngineandRuleinstances are thread-safe; you can share them globally.Sessioninstances are not thread-safe; keep each one local to a single thread.- The native-handle types (
Engine,Rule,Session,TracedSession,DataHandle) implementIDisposable.Dispose()frees the handle exactly once, even when several threads call it. If you never call it, a finalizer releases the native memory; dispose anyway, so native memory does not wait for the garbage collector. EngineBuilderis not disposable.Build()releases its native builder, and a finalizer frees a builder you drop beforeBuild().- Rules, sessions and traced sessions keep the engine’s custom-operator callbacks alive, so custom operators keep working after the
Engineis disposed or finalized.
Going deeper
- C ABI internals: memory management & thread safety: the native-heap ownership rules every FFI binding shares
- Engine configuration semantics and Operation Budget
- Rule analysis: diagnostic codes, the
Facts()fields, and the operator catalogue - Thread safety: cross-language concurrency patterns
- Package README on NuGet: full API surface, error types, and platform table
PHP (FFI)
The PHP binding goplasmatic/datalogic calls the shared C ABI through PHP’s FFI extension (ext-ffi). It requires PHP 8.4 or newer.
Installation
Add the Composer dependency to your project:
composer require goplasmatic/datalogic
Composer records the constraint ^5.9.
Enable PHP’s FFI extension in your php.ini configuration. The binding supports two setups:
extension=ffi
; Simplest setup (CLI tools, or any SAPI): allow runtime FFI::cdef
ffi.enable=true
For PHP-FPM and other web SAPIs, PHP’s default ffi.enable=preload forbids runtime FFI::cdef outside the CLI, so preload the package’s FFI scope instead (it also moves header parsing to server start):
extension=ffi
opcache.preload=/path/to/vendor/goplasmatic/datalogic/preload.php
opcache.preload_user=www-data
ffi.enable=preload
The binding looks for the preloaded FFI::scope("datalogic") first and falls back to FFI::cdef when no scope is registered. Details are in the Preloading section of the PHP README.
The Composer package ships precompiled shared libraries under lib/<os>-<arch>/ for Linux, macOS and Windows on x86_64 and arm64, and the loader picks the one for the current platform. Set the DATALOGIC_NATIVE_LIB environment variable to an absolute path to load a library you built yourself.
Quick Start
One-Shot Evaluation
<?php
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
$result = $engine->apply('{"+": [1, 2, 3]}', '{}');
echo $result; // "6"
Reusable Compiled Rules
Compile rules that you evaluate repeatedly. Compiling parses and optimizes the rule once on the Rust side:
<?php
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
$rule = $engine->compile('{"if": [{ ">": [{"var": "score"}, 50] }, "pass", "fail"]}');
echo $rule->evaluate(json_encode(['score' => 75])), "\n"; // "pass"
echo $rule->evaluate(json_encode(['score' => 30])), "\n"; // "fail"
// Close the handles to free native memory now instead of at garbage collection
$rule->close();
$engine->close();
Arena Recycling with Session
To recycle memory allocations in hot loops, open a Session:
<?php
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
$rule = $engine->compile('{"var": "user.name"}');
$session = $engine->openSession();
foreach ($dataset as $input) {
// Reuses the session's arena, reset at the start of each call
$name = $session->evaluate($rule, $input);
echo $name, "\n";
}
$session->close();
$rule->close();
$engine->close();
Checking and Inspecting Rules
The engine can report problems in a rule before it runs, and describe a compiled rule:
<?php
use Goplasmatic\Datalogic\Engine;
use Goplasmatic\Datalogic\Exception\ParseException;
$engine = new Engine();
echo $engine->check('{"if": [true, {"vr": "x"}]}');
// [{"code":"UnknownOperator","severity":"error",
// "message":"unknown operator `vr`; did you mean `var`?","pointer":"/if/1","operator":"vr"}]
try {
$rule = $engine->compileChecked($ruleJson);
echo $rule->facts(); // {"reads":[["user","age"]], "operators":[...], "deterministic":true, ...}
} catch (ParseException $e) {
echo $e->diagnosticsJson; // errorType "CompileError": every problem, each with a JSON Pointer
}
| Method | Returns |
|---|---|
$engine->check($rule, $mode = Native::MODE_ENGINE) | Every problem the engine can see, as a JSON array of {code, severity, message, pointer, operator}; it does not throw for a bad rule |
$engine->compileChecked($rule) | A Rule, or ParseException with $errorType === "CompileError" if check finds an error |
$engine->compileTemplate($rule) / compileStrict($rule) | A Rule compiled with templating on or off, whatever the engine’s own mode |
$rule->facts() | The data paths the rule reads, the operators it calls, and whether it is deterministic, as JSON |
$engine->operators() | The operator catalogue, in the schema of operators.json |
$engine->truthy($valueJson) | Whether a JSON value is truthy under the engine’s configured truthiness |
Engine::builder() also takes withFamilies('ExtString', ...), which keeps the engine to the JSONLogic core plus the operator families you name, and withStrictOperatorNames(true), which makes addOperator throw for a name a built-in operator answers to. The engine config accepts "missing_var": "error", which turns a var read that finds nothing into a VariableNotFound error.
Memory Management
PHP releases FFI-allocated memory when wrapper objects go out of scope and the PHP engine collects them. In long-running environments (such as PHP-FPM, Swoole, RoadRunner, or CLI daemons), garbage collection delays can let heap usage build up.
To free native memory at a known point, call $object->close() on the Engine, Rule, Session, TracedSession or DataHandle wrapper. A Rule, Session or TracedSession keeps its engine (and the engine’s custom operators) alive, so closing the engine does not break them. Each native handle has one owner: wrapping a handle that a wrapper already owns throws InvalidArgumentException.
Going deeper
- C ABI internals: memory management & thread safety: the native-heap ownership rules every FFI binding shares
- Engine configuration semantics and Operation Budget
- Rule analysis: diagnostic codes, the
facts()fields, and the operator catalogue - Thread safety: cross-language concurrency patterns
- Package README on Packagist: full API surface, error types, and platform table
C ABI: Embedding & Writing New Bindings
For language runtimes without direct Rust interoperability libraries (like pyo3 or napi-rs), datalogic-rs exposes a stable C ABI in bindings/c. It is how the Go, JVM (Java/Kotlin), .NET (C#), and PHP bindings talk to the core, and it is the starting point if you want to embed the engine in a language we don’t ship yet.
+-------------------+
| datalogic-rs Core |
+---------+---------+
| (Rust path-dependency)
+---------v---------+
| bindings/c | (C ABI, generates datalogic.h / libdatalogic_c)
+----+----+----+----+
| | | |
| | | +---> PHP FFI (goplasmatic/datalogic)
| | +--------> .NET P/Invoke (Goplasmatic.Datalogic)
| +-------------> JVM FFM (io.github.goplasmatic:datalogic)
+------------------> Go cgo (github.com/GoPlasmatic/datalogic-rs/bindings/go/v5)
The full function-by-function surface, status codes, build instructions, and cbindgen notes live in the C ABI README.
ABI versioning
The ABI is v2: datalogic_abi_version() returns 2, and a wrapper refuses to load a library that reports anything else. Additions to v2 count up in datalogic_abi_minor(). Minor 1 shipped with 5.8.0 and added per-compile modes (datalogic_engine_compile_mode), datalogic_engine_compile_checked, datalogic_engine_check, the operator catalogue (datalogic_engine_operators), datalogic_engine_truthy, rule facts (datalogic_rule_facts), metered session evaluation, the builder’s families, template key escape and strict operator names, traced evaluation in a mode, and the datalogic_error_node_ids_json / datalogic_error_diagnostics_json accessors. A wrapper that calls those entry points checks datalogic_abi_minor() >= 1 at load.
Binary distribution
Because these bindings rely on compiled shared/static libraries, the release pipeline compiles the bindings/c code for every supported operating system and architecture, then bundles the binaries into each ecosystem’s standard package layout.
| Ecosystem | Packaging | Binaries Layout | Loading Mechanism |
|---|---|---|---|
| Go | Go Module | Static libraries in lib/<os>_<arch>/ | cgo static linking at compile time |
| JVM | Maven JAR | Shared libraries at the classpath root under <os-arch>/ | FFM (java.lang.foreign) at runtime |
| .NET | NuGet | Shared libraries under runtimes/<rid>/native/ | P/Invoke LibraryImport at runtime |
| PHP | Composer | Shared libraries under lib/<os>-<arch>/ | PHP FFI: preloaded FFI::scope (opcache.preload) with FFI::cdef fallback |
The JSON-in/JSON-out rule
Inputs and outputs cross the boundary as UTF-8 JSON passed as (pointer, length) pairs. ABI v2 carries an explicit byte length, so there are no NUL terminators, and embedded NULs or non-ASCII bytes are safe.
The boundary does no struct marshaling. The host language serializes inputs to JSON and passes them to Rust; Rust evaluates them and returns the result as JSON bytes for the host to parse. Diagnostics, rule facts, the operator catalogue and traced runs cross the same way, in the JSON formats every binding shares.
Memory management & safety
Because the Go, JVM, .NET, and PHP bindings interface with the Rust core over a C FFI boundary, memory management rules differ from native Go/Java/C#/PHP code.
The danger: native memory leaks
If you instantiate an Engine or compile a Rule in a managed language, the core allocates the actual structures (compiled node trees, configuration, operator registrations) on the native Rust heap and returns only a raw memory pointer to your host language.
Managed garbage collectors (like the JVM, .NET CLR, Go’s GC, or PHP’s Zend GC) only track the size of the wrapper object itself (a few bytes holding the pointer address). The GC cannot see the native memory behind that pointer, which can run to megabytes.
Every host has a fallback for a handle you never close: the JVM frees it through a shared java.lang.ref.Cleaner once the wrapper is unreachable, Go and .NET register finalizers, and PHP releases it in the wrapper’s destructor when the object goes out of scope. The JVM, Go and .NET fallbacks run only when the collector runs, and the collector cannot see the native memory, so a busy process can hold far more native memory than its heap suggests. None of these fallbacks replaces explicit cleanup.
Best practices per language
Follow these patterns to release native memory as soon as you are done with it:
Go: explicit cleanup with defer
Every Go handle type (Engine, Rule, Session, TracedSession, DataHandle) registers a runtime.SetFinalizer fallback, and so does an EngineBuilder you never Build, but finalizers run only when the GC notices the wrapper, which may be long after the native memory stopped being useful. defer .Close() on each handle.
engine := datalogic.NewEngine()
defer engine.Close() // ALWAYS defer Close
rule, err := engine.Compile(ruleJSON)
if err != nil {
return err
}
defer rule.Close() // ALWAYS defer Close
session := engine.Session()
defer session.Close() // ALWAYS defer Close
JVM: try-with-resources
Java and Kotlin provide the try-with-resources statement. Every native-handle class (Engine, Rule, Session, TracedSession, DataHandle) implements AutoCloseable, which makes this the safest pattern. EngineBuilder is not closeable: build() releases its native handle, and the Cleaner frees a builder you never build.
// Automatic closure of Engine and Rule
try (Engine engine = new Engine();
Rule rule = engine.compile(ruleStr)) {
// Automatic closure of Session
try (Session session = engine.openSession()) {
String result = session.evaluate(rule, data);
}
} // Engine, Rule, and Session are guaranteed to be closed here
.NET: using statements
In C#, use the using keyword. If you forget, the C# wrapper’s finalizer frees the handle when the collector runs; dispose each handle instead of relying on it:
using var engine = new Engine();
using var rule = engine.Compile(ruleJSON);
using (var session = engine.OpenSession())
{
var result = session.Evaluate(rule, data);
} // Session is disposed here
// Engine and Rule are disposed when the current method scope ends
PHP: scope-destructors & close()
PHP releases FFI objects when they fall out of scope. For CLI daemons, Swoole services, or long-running PHP-FPM requests, close handles manually:
$engine = new Engine();
$rule = $engine->compile($ruleJSON);
$session = $engine->openSession();
$result = $session->evaluate($rule, $data);
// Explicit cleanup prevents memory creep in long-running processes
$session->close();
$rule->close();
$engine->close();
Closing handles and custom operators
Close() / close() / Dispose() frees a handle exactly once, even when several threads call it at the same time (Go swaps the pointer atomically, the JVM uses AtomicReference.getAndSet, .NET Interlocked.Exchange). Closing a handle while another thread is still using it is not supported in any host.
A rule, session or traced session holds its own reference on the native engine, so it keeps working after you close the Engine, custom operators included: each host keeps the operator callbacks alive while any handle derived from the engine is reachable.
A custom-operator callback may evaluate through the same engine, but not through the session that is running it. That session is in use until the callback returns: its evaluate calls fail with DATALOGIC_STATUS_INVALID_ARG, and reset / free on it do nothing. Open a second session, or evaluate through the rule, for a nested evaluation.
Thread safety & concurrency
Each handle type has its own thread-safety rule:
| Class / Type | Thread-Safe? | Usage Pattern |
|---|---|---|
Engine | Yes | Construct once globally; share across all threads/goroutines. |
Rule | Yes | Compile once; share and call Evaluate() concurrently. |
Session | No | Never share sessions. Keep one Session instance per thread. |
TracedSession | Yes | Open once; evaluate concurrently. |
DataHandle | Yes | Parse once; share across threads and engines (evaluation only reads it); close after the last evaluation. |
EngineBuilder | No | Configure and build on one thread, then share the resulting Engine. |
Why Session is not thread-safe
Session contains a bumpalo arena allocator, which allocates by moving a cursor forward through pre-allocated memory. If two threads evaluate logic concurrently with the same session, they overwrite each other’s memory and cause crashes or data corruption.
See also
- Language bindings built on the C ABI: Go (
cgo), Java / Kotlin (java.lang.foreign), .NET (P/Invoke), and PHP (ext-ffi) - Engine guides: Thread Safety, Custom Operators, Configuration, Rule Analysis, and Operation Budget
- Full C header & contract:
bindings/c/README.mdandbindings/BINDINGS.md
Installation
The @goplasmatic/datalogic-ui package provides a React component for visualizing and debugging JSONLogic expressions as interactive flow diagrams.
Package Installation
# npm
npm install @goplasmatic/datalogic-ui @xyflow/react
# yarn
yarn add @goplasmatic/datalogic-ui @xyflow/react
# pnpm
pnpm add @goplasmatic/datalogic-ui @xyflow/react
Peer Dependencies
The package requires:
| Package | Version range | Purpose |
|---|---|---|
react | ^18.0.0 || ^19.0.0 | React framework |
react-dom | ^18.0.0 || ^19.0.0 | React DOM renderer |
@xyflow/react | ^12.0.0 | Flow diagram rendering |
The package has no runtime dependencies beyond these. Do not install
@goplasmatic/datalogic-wasm for the editor: the WASM engine, built from the
same datalogic-rs release (5.9.0), ships inside the package’s dist with the
.wasm binary inlined, so your bundler needs no WASM loader. @dagrejs/dagre,
lucide-react and uuid are bundled as well.
CSS Setup
One import, in your application entry point or component:
import '@goplasmatic/datalogic-ui/styles.css';
React Flow’s base styles are vendored into the package’s styles.css, so
there is no separate @xyflow/react/dist/style.css import and no import-order
requirement. @xyflow/react itself stays a peer dependency because the
component’s JavaScript uses it; only its stylesheet is bundled.
The bundled React Flow rules and the editor’s own handle and edge overrides
apply only inside the editor’s .logic-editor root, so they leave any other
React Flow canvas on the page alone. If your app renders its own React Flow
canvas, import @xyflow/react/dist/style.css for that canvas as usual.
Minimal Example
import '@goplasmatic/datalogic-ui/styles.css';
import { DataLogicEditor } from '@goplasmatic/datalogic-ui';
function App() {
return (
<div style={{ width: '100%', height: '500px' }}>
<DataLogicEditor
value={{ "==": [{ "var": "x" }, 1] }}
/>
</div>
);
}
Container Requirements
The editor fills its parent (width: 100%; height: 100%), so the parent needs
a height:
// Option 1: Explicit dimensions
<div style={{ width: '100%', height: '500px' }}>
<DataLogicEditor value={expression} />
</div>
// Option 2: CSS class
<div className="editor-container">
<DataLogicEditor value={expression} />
</div>
// CSS
.editor-container {
width: 100%;
height: 100vh;
}
TypeScript Setup
The package ships its own types. Import them as needed:
import type {
DataLogicEditorProps,
DataLogicEvaluationConfig,
DataLogicCustomOperator,
JsonLogicValue,
} from '@goplasmatic/datalogic-ui';
A JSONLogic literal whose array holds two different operator keys needs the
annotation, or TypeScript widens it into a union JsonLogicValue does not
accept:
const expression: JsonLogicValue = {
and: [
{ '>': [{ var: 'age' }, 18] },
{ '==': [{ var: 'status' }, 'active'] },
],
};
See Props & API for the full export list.
Bundler Notes
The package publishes an ES module build (import, dist/index.js) and a
CommonJS build (require, dist/index.cjs). Both start the WASM engine, so
Jest, CommonJS server rendering and older bundlers that pick the require
entry get evaluation and the debugger. If the engine fails to load, an editor
that has data shows a banner above the diagram with the load error and keeps
the static diagram.
Vite
Needs no additional configuration.
Webpack
Configure CSS loaders:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
};
Next.js
For App Router, use client components:
'use client';
import '@goplasmatic/datalogic-ui/styles.css';
import { DataLogicEditor } from '@goplasmatic/datalogic-ui';
export function LogicVisualizer({ expression }) {
return <DataLogicEditor value={expression} />;
}
Next Steps
- Quick Start: basic visualization, debugging, and editing examples
- Modes: read-only, debugger, editing, and templating modes
- Props & API: complete props and utility exports reference
- Customization: CSS variables, dark/light themes, and custom React Flow nodes
- Playground: try the component live in your browser
Quick Start
The examples below cover common patterns for the DataLogicEditor component.
Basic Visualization
Render a JSONLogic expression as a flow diagram:
import '@goplasmatic/datalogic-ui/styles.css';
import { DataLogicEditor, type JsonLogicValue } from '@goplasmatic/datalogic-ui';
function App() {
// Annotate the literal: TypeScript widens an array holding two different
// operator keys into a union that JsonLogicValue does not accept.
const expression: JsonLogicValue = {
and: [
{ '>': [{ var: 'age' }, 18] },
{ '==': [{ var: 'status' }, 'active'] },
],
};
return (
<div style={{ width: '100%', height: '500px' }}>
<DataLogicEditor value={expression} />
</div>
);
}
Debugging
Provide a data context to turn on the debugger. The toolbar gains play/pause, step, and a step timeline over the engine’s execution trace:
function DebugExample() {
const expression = {
"if": [
{ ">=": [{ "var": "score" }, 90] }, "A",
{ ">=": [{ "var": "score" }, 80] }, "B",
"C"
]
};
const userData = {
score: 85
};
return (
<div style={{ width: '100%', height: '500px' }}>
<DataLogicEditor
value={expression}
data={userData}
/>
</div>
);
}
As you step, the current node shows its context and result in a bubble, and executed nodes stay highlighted so the taken path is visible. If evaluation fails, the editor marks the node on the engine’s failure breadcrumb with the error (and a rule that does not compile at all reports the error in a banner above the diagram). Nodes do not display results at rest, so step through the trace to read values. If the WASM engine fails to load, a banner above the diagram gives the load error and the diagram stays static.
Dynamic Data
Update evaluation results by changing the data:
import { useState } from 'react';
function DynamicDebugger() {
const [score, setScore] = useState(75);
const expression = {
"if": [
{ ">=": [{ "var": "score" }, 90] }, "A",
{ ">=": [{ "var": "score" }, 80] }, "B",
{ ">=": [{ "var": "score" }, 70] }, "C",
"F"
]
};
return (
<div>
<div>
<label>
Score:
<input
type="range"
min="0"
max="100"
value={score}
onChange={(e) => setScore(Number(e.target.value))}
/>
{score}
</label>
</div>
<div style={{ width: '100%', height: '400px' }}>
<DataLogicEditor
value={expression}
data={{ score }}
/>
</div>
</div>
);
}
Complex Expressions
The editor handles complex nested expressions:
function ComplexExample() {
const expression = {
"and": [
{ "or": [
{ "==": [{ "var": "user.role" }, "admin"] },
{ "==": [{ "var": "user.role" }, "moderator"] }
]},
{ ">=": [{ "var": "user.accountAge" }, 30] },
{ "!": [{ "var": "user.banned" }] }
]
};
const data = {
user: {
role: "moderator",
accountAge: 45,
banned: false
}
};
return (
<div style={{ width: '100%', height: '600px' }}>
<DataLogicEditor
value={expression}
data={data}
/>
</div>
);
}
Array Operations
Visualize array operations like map, filter, and reduce:
function ArrayExample() {
const expression = {
"filter": [
{ "var": "items" },
{ ">": [{ "var": "price" }, 20] }
]
};
const data = {
items: [
{ name: "Book", price: 15 },
{ name: "Phone", price: 299 },
{ name: "Pen", price: 5 }
]
};
return (
<div style={{ width: '100%', height: '400px' }}>
<DataLogicEditor
value={expression}
data={data}
/>
</div>
);
}
Editing
Set editable to turn on the visual builder: node selection, a properties panel, context menus, and undo/redo. Pair it with value and onChange to keep your own state in sync (onChange fires debounced, about 300ms, with the rebuilt JSONLogic):
import { useState } from 'react';
import { DataLogicEditor, type JsonLogicValue } from '@goplasmatic/datalogic-ui';
function EditableExample() {
// `onChange` hands back `JsonLogicValue | null`, so type the state to match.
const [expression, setExpression] = useState<JsonLogicValue | null>({
'>': [{ var: 'cart.total' }, 100],
});
return (
<div style={{ width: '100%', height: '600px' }}>
<DataLogicEditor
value={expression}
onChange={setExpression}
editable
/>
</div>
);
}
Feeding the value from onChange back through value keeps the selection,
the open properties panel, and the pan and zoom.
Add data to combine editing with live debugging in the same view. In edit
mode the toolbar also gains an Insert button (Cmd/Ctrl+K) that adds an
argument to the selection or wraps the root, and the canvas supports
copy/paste (Cmd/Ctrl+C / V), duplicate (Cmd/Ctrl+D), select-all (Cmd/Ctrl+A),
undo/redo (Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z or Cmd/Ctrl+Y) and delete
(Backspace/Delete). These shortcuts work only while focus is inside the
editor; click it first.
Engine Settings
Pass config to change how the engine evaluates, for both the result and the
trace. Every key is optional and omitted keys keep the engine default:
<DataLogicEditor
value={expression}
data={data}
config={{
preset: 'strict',
division_by_zero: 'return_null',
truthy_evaluator: 'python',
}}
/>
When the settings differ from the defaults, the toolbar shows a compact
summary so you can trace a surprising result to the configuration. A config
with different settings rebuilds the engine, which resets selection, undo
history and the debugger position. An inline object literal that carries the
same settings on every render does not.
Custom Operators
Register your own operators; rules that use them evaluate and trace like any
other. Arguments arrive already evaluated, and the return value can be any
JSON-serializable value (undefined becomes null; a thrown exception
becomes a runtime evaluation error):
<DataLogicEditor
value={{ discounted: [{ var: 'price' }] }}
data={{ price: 100 }}
customOperators={{
discounted: (args) => Number(args[0]) * 0.9,
}}
/>
The palette and help panel only know the built-in operators, so custom nodes render with the generic “utility” styling.
Theme Support
The editor has light and dark themes:
// Explicit theme
<DataLogicEditor
value={expression}
theme="dark"
/>
// System preference (default)
<DataLogicEditor value={expression} />
The component sets data-theme on its own .logic-editor root and does not read a data-theme on a parent or ancestor. Use the theme prop to force a theme.
Handling Null/Empty Expressions
Pass null and the editor renders an empty state (“No expression”):
function ConditionalEditor({ expression }) {
return (
<div style={{ width: '100%', height: '400px' }}>
<DataLogicEditor
value={expression} // Can be null
/>
</div>
);
}
Styling Container
Add custom styling to the container:
<DataLogicEditor
value={expression}
className="my-custom-editor"
/>
// CSS
.my-custom-editor {
border: 1px solid #ccc;
border-radius: 8px;
}
Next Steps
- Modes: detailed mode documentation
- Props & API: complete props reference
- Customization: theming and styling
Usage Modes
The DataLogicEditor has no mode enum. The props you pass determine its behavior. The same component is a read-only viewer, a live debugger, a visual editor, or any combination of those, depending on data, editable, and templating.
Behavior Overview
| Behavior | Enabled by | Description | Requires data |
|---|---|---|---|
| Read-only | (none) | Static diagram visualization | No |
| Debugger | data | Step-through execution trace with a step timeline and failure highlighting | Yes |
| Editing | editable | Visual builder: node selection, properties panel, context menus, undo/redo | No |
| Templating | templating | Multi-key objects and arrays become output-shaping templates | No |
| Engine settings | config | Evaluation semantics: presets, NaN and division-by-zero handling, truthiness, coercion, recursion cap, operation budget | No |
| Custom operators | customOperators | Extra operators registered on the engine | No |
You can combine these. Setting editable and providing data at the same time gives you live debugging while you edit.
Read-only (Default)
With only a value, the editor renders a static flow diagram of the JSONLogic expression.
<DataLogicEditor value={expression} />
Use cases:
- Documentation and explanation
- Code review and understanding
- Static representation in reports
Features:
- Interactive pan and zoom
- Node highlighting on hover
- Tree-based automatic layout
- Nodes coloured by the type of value they produce (boolean, number, string, collection, data, temporal, null), with a category icon in the header
- A Flow/Hierarchy toolbar toggle: Flow (default) puts sources on the left and the result on the right, Hierarchy puts the root on the left in JSON nesting order. The editor reflects the choice as
data-directionon the.logic-editorroot
Debugging
Provide a data prop and the editor evaluates the expression with the engine’s tracing API and exposes debugger controls for stepping through the execution.
<DataLogicEditor
value={expression}
data={contextData}
/>
Use cases:
- Understanding evaluation flow
- Debugging unexpected results
- Testing expressions with different inputs
- Learning JSONLogic
Features:
- All read-only features, plus:
- Play/pause, step forward and back, and jump to first/last (Space, arrow keys, Home/End)
- A step timeline listing every recorded step with its node, iteration index and result (or error); click a row to jump to that step
- A bubble on the current node showing the context it evaluated against and the value it produced
- A highlighted execution path, so you can see which branch ran
- Failure reporting: the editor marks the node on the engine’s failure breadcrumb (
node_idsin the structured error) with the error, and a rule that fails to compile reports the error in a banner above the diagram - An engine banner: if the WASM engine fails to load, a banner above the diagram gives the load error and the diagram stays static
Nodes show values only on the current step, not at rest.
With data, the component calls the engine’s evaluateWithTrace, which records the result of each sub-expression, the order of evaluation, the context at each step, and the final result. The engine also returns, for each node, the JSON Pointer into the rule that the node was compiled from, and the debugger uses those pointers to place each step on the node you wrote, aliases such as ?: included.
Editing
Set editable to turn on the full visual builder.
<DataLogicEditor
value={expression}
onChange={setExpression}
editable
/>
Features:
- Node selection
- Properties panel for the selected node, with per-operator help and a link to that operator’s documentation page
- Context menus (right-click a node or the canvas)
- An Insert toolbar button (Cmd/Ctrl+K) that adds an argument to the selection, wraps it, or targets the root
- Undo/redo (up to 50 steps), from the toolbar or the keyboard
- Keyboard shortcuts: copy/paste (Cmd/Ctrl+C / V), duplicate (Cmd/Ctrl+D), select all (Cmd/Ctrl+A), undo/redo (Cmd/Ctrl+Z, Shift+Cmd/Ctrl+Z or Cmd/Ctrl+Y), delete (Backspace/Delete), deselect (Escape)
Shortcuts, the debugger’s included, apply only while focus is inside the editor; clicking anywhere in it gives it focus. A page with several editors, or with shortcuts of its own, keeps its keys. Text fields keep their editing keys, and buttons and links keep Space and Enter.
When editable is set, onChange is active: the editor debounces edits (about 300ms) and passes back the rebuilt JSONLogic expression so you can keep your own state in sync. Feeding that value back through value keeps the selection, the open properties panel, and the pan and zoom. Any other new value object from your code counts as a new rule: the canvas remounts and fits the view to it.
Editing with Live Debugging
Combine editable with data to edit and debug in the same view: the trace re-runs as you build, so you can step through the expression you are editing.
<DataLogicEditor
value={expression}
onChange={setExpression}
data={contextData}
editable
/>
Templating
Set templating so that multi-key objects and arrays in the compiled rule become output-shaping templates with embedded JSONLogic, rather than being rejected as invalid JSONLogic. This matches the v5 core API (Engine::builder().with_templating(true)). Passing onTemplatingChange adds a Templating checkbox to the toolbar; with templating alone the mode is fixed and no checkbox renders.
<DataLogicEditor
value={expression}
templating={templating}
onTemplatingChange={setTemplating}
/>
Engine Settings and Custom Operators
config changes evaluation semantics for both the result and the trace, and
customOperators registers extra operators on the engine:
<DataLogicEditor
value={expression}
data={contextData}
config={{ preset: 'safe_arithmetic', truthy_evaluator: 'python' }}
customOperators={{ double: (args) => Number(args[0]) * 2 }}
/>
The toolbar shows a summary whenever settings differ from the engine defaults.
The editor rebuilds the engine, resetting selection, undo history and the
debugger position, only when the settings in config or the set of
customOperators names change. Inline object literals are fine: an equal
config or a new function under an existing name keeps the engine. See
Props & API for every key.
Behavior Comparison
| Aspect | Read-only | Debugger (data) | Editing (editable) |
|---|---|---|---|
| Node display | Structure only | Structure, plus values on the current step | Editable nodes |
| Interactivity | Pan/zoom | Pan/zoom + stepping | Full editing |
data required | No | Yes | No |
| Output | Static | Static + trace | Two-way bound via onChange |
Performance Considerations
- Read-only is fastest: no evaluation overhead.
- Debugger re-runs the trace when the content of
datachanges. A new object with the same content does not re-run it. - Editing rebuilds the expression on each change (debounced before
onChangefires).
For large expressions or frequent data updates, defer the data you pass in:
import { useDeferredValue } from 'react';
function DebugWithDeferred({ expression, data }) {
const deferredData = useDeferredValue(data);
return (
<DataLogicEditor
value={expression}
data={deferredData}
/>
);
}
Toggling Behavior at Runtime
Because behavior is prop-driven, you toggle it by toggling props. For example, to switch between plain visualization and debugging, conditionally pass data:
function DebugToggle() {
const [debug, setDebug] = useState(false);
return (
<div>
<button onClick={() => setDebug((d) => !d)}>
{debug ? 'Stop debugging' : 'Debug'}
</button>
<DataLogicEditor
value={expression}
data={debug ? data : undefined}
/>
</div>
);
}
Next Steps
- Props & API: complete props reference
- Customization: theming and styling
Props & API Reference
Complete reference for the DataLogicEditor component and related exports.
DataLogicEditor Props
Required Props
value
The JSONLogic expression to render.
value: JsonLogicValue | null
Accepts any valid JSONLogic expression or null for an empty state. An
expression nested deeper than 100 levels renders an error instead of a
diagram.
The editor compares value by reference. When you pass back the value
onChange handed you (or an equal copy), the canvas keeps its selection, open
properties panel, pan and zoom. Any other new object counts as a new rule: the
canvas remounts and fits the view to it. In a parent that re-renders often,
keep value in state or useMemo rather than writing a fresh literal on each
render.
// Simple expression
<DataLogicEditor value={{ "==": [1, 1] }} />
// Complex expression
<DataLogicEditor value={{
"and": [
{ ">=": [{ "var": "age" }, 18] },
{ "var": "active" }
]
}} />
// Null for empty state
<DataLogicEditor value={null} />
Optional Props
data
Data context for evaluation. When provided, the editor evaluates the expression through the WASM trace API and the debugger controls become available. Any JSON value is a valid root context: object, array or scalar. Values appear on nodes as you step, not at rest (see Modes). The editor re-runs the trace when the content of data changes, so an inline object is fine. If the WASM engine fails to load, a banner above the diagram gives the load error.
data?: unknown
<DataLogicEditor
value={{ "var": "user.name" }}
data={{ user: { name: "Alice" } }}
/>
onChange
Callback fired when the expression changes. It is active whenever editable is set: the editor debounces canvas edits (about 300ms) and passes back the rebuilt JSONLogic expression. Passing that value back as value keeps the selection, the open properties panel, and the pan and zoom.
onChange?: (expr: JsonLogicValue | null) => void
<DataLogicEditor
value={expression}
onChange={setExpression}
editable
/>
editable
Enable editing: node selection, properties panel, context menus, the Insert menu (Cmd/Ctrl+K), keyboard shortcuts, and undo/redo. Keyboard shortcuts apply only while focus is inside this editor, so two editors on one page, or a page with its own shortcuts, do not take each other’s keys.
editable?: boolean
Default: false
<DataLogicEditor value={expr} onChange={setExpr} editable />
templating
Enable templating mode: multi-key objects and arrays in compiled rules become output-shaping templates with embedded JSONLogic expressions, rather than being rejected as invalid JSONLogic. Matches the v5 core API (Engine::builder().with_templating(true)).
templating?: boolean
Default: false
<DataLogicEditor value={expr} templating />
onTemplatingChange
Callback fired when templating mode changes from the toolbar checkbox. The checkbox renders only when this prop is provided; with templating alone the mode is fixed.
onTemplatingChange?: (value: boolean) => void
<DataLogicEditor
value={expr}
templating={templating}
onTemplatingChange={setTemplating}
/>
exampleSuggestions
Optional list of example names to surface as quick-action chips in the empty state. Each chip, when clicked, calls onSelectExample with the corresponding name. Chips render only when you provide both exampleSuggestions and onSelectExample and the editor is empty.
exampleSuggestions?: string[]
<DataLogicEditor
value={null}
exampleSuggestions={['Age check', 'Discount rule']}
onSelectExample={loadExample}
/>
onSelectExample
Callback invoked when a user clicks an empty-state example chip. Receives the example name from exampleSuggestions.
onSelectExample?: (name: string) => void
config
Engine evaluation settings, applied to both the plain result and the traced
run. Mirrors the core EvaluationConfig; every key is optional and omitted
keys keep the engine default (or the selected preset’s value).
config?: DataLogicEvaluationConfig
interface DataLogicEvaluationConfig {
preset?: 'default' | 'safe_arithmetic' | 'strict';
arithmetic_nan_handling?: 'throw_error' | 'ignore_value' | 'coerce_to_zero' | 'return_null';
division_by_zero?: 'return_saturated' | 'throw_error' | 'return_null' | 'return_infinity';
loose_equality_errors?: boolean;
truthy_evaluator?: 'javascript' | 'python' | 'strict_boolean';
numeric_coercion?: {
empty_string_to_zero?: boolean;
null_to_zero?: boolean;
bool_to_number?: boolean;
reject_non_numeric?: boolean;
};
max_recursion_depth?: number;
ops_budget?: number;
}
<DataLogicEditor
value={expr}
data={data}
config={{ preset: 'strict', division_by_zero: 'return_null' }}
/>
The toolbar shows a compact summary whenever the settings differ from the
engine defaults. The editor keys the engine on the settings, not on the object:
an inline literal is fine, and only a change of setting rebuilds the engine,
which resets selection, undo history and the debugger position. The engine
rejects an unknown key or value with a ConfigurationError. See
Configuration for what each setting does.
ops_budget caps the operations one evaluation may charge (see
Operation Budget). Crossing it raises a
BudgetExceeded error that try cannot catch.
customOperators
Custom operators registered on the evaluation engine, keyed by operator name.
customOperators?: Record<string, (args: unknown[]) => unknown>
<DataLogicEditor
value={{ discounted: [{ var: 'price' }] }}
data={{ price: 100 }}
customOperators={{ discounted: (args) => Number(args[0]) * 0.9 }}
/>
Arguments arrive already evaluated; the return value may be any
JSON-serializable value (undefined becomes null), and a thrown exception
becomes a runtime evaluation error. Rules using them evaluate and trace like
any other, but the palette and help panel only know built-in operators, so
custom nodes render with the generic “utility” styling. Built-ins win a name
collision: registering "+" has no effect. Adding or removing a name
rebuilds the engine; passing new implementations under the same names (an
inline object on every render, say) does not, because the engine calls the
latest function for each name.
theme
Theme override.
theme?: 'light' | 'dark'
Default: System preference
<DataLogicEditor value={expr} theme="dark" />
The component writes data-theme onto its own .logic-editor root; it does not read a data-theme set on an ancestor.
className
Additional CSS class for the container.
className?: string
<DataLogicEditor value={expr} className="my-editor" />
Type Definitions
JsonLogicValue
The type for JSONLogic expressions:
type JsonLogicPrimitive = string | number | boolean | null;
type JsonLogicExpression = {
[operator: string]: JsonLogicValue | JsonLogicValue[];
};
type JsonLogicValue =
| JsonLogicPrimitive
| JsonLogicPrimitive[]
| JsonLogicExpression
| JsonLogicValue[];
Only JsonLogicValue is exported.
Annotate expression literals whose arrays hold more than one operator key. Without the annotation TypeScript widens the array into a union of per-key object types, which the index signature does not accept:
const expression: JsonLogicValue = {
and: [
{ '>': [{ var: 'age' }, 18] },
{ '==': [{ var: 'status' }, 'active'] },
],
};
DataLogicEditorProps
interface DataLogicEditorProps {
value: JsonLogicValue | null;
onChange?: (expr: JsonLogicValue | null) => void;
data?: unknown;
theme?: 'light' | 'dark';
className?: string;
templating?: boolean;
onTemplatingChange?: (value: boolean) => void;
config?: DataLogicEvaluationConfig;
customOperators?: Record<string, DataLogicCustomOperator>;
editable?: boolean;
exampleSuggestions?: string[];
onSelectExample?: (name: string) => void;
}
LogicNode
A React Flow node carrying the package’s custom node data (for advanced customization):
import type { Node } from '@xyflow/react';
type LogicNode = Node<LogicNodeData>;
type LogicNodeData = OperatorNodeData | LiteralNodeData | StructureNodeData;
The data payload is one of three shapes, discriminated by its type field:
interface OperatorNodeData {
type: 'operator';
operator: string;
category: OperatorCategory;
label: string;
icon: IconName;
cells: CellData[]; // all arguments as rows
collapsed?: boolean;
expressionText?: string; // single-line text when collapsed
}
interface LiteralNodeData {
type: 'literal';
value: JsonLogicValue;
valueType: 'string' | 'number' | 'boolean' | 'null' | 'array';
}
interface StructureNodeData {
type: 'structure';
isArray: boolean;
formattedJson: string;
elements: StructureElement[];
collapsed?: boolean;
expressionText?: string;
}
LogicEdge
An alias for the React Flow Edge type:
import type { Edge } from '@xyflow/react';
type LogicEdge = Edge;
OperatorCategory
type OperatorCategory =
| 'variable'
| 'comparison'
| 'logical'
| 'arithmetic'
| 'control'
| 'string'
| 'array'
| 'object'
| 'datetime'
| 'validation'
| 'error'
| 'utility'
| 'flagd'
| 'tensor';
CATEGORY_COLORS is keyed by NodeCategory, which is OperatorCategory plus
'literal' for literal nodes.
Exports
Component
import { DataLogicEditor } from '@goplasmatic/datalogic-ui';
Types
import type {
DataLogicEditorProps,
DataLogicEvaluationConfig,
DataLogicCustomOperator,
JsonLogicValue,
JsonLogicToNodesOptions,
LogicNode,
LogicEdge,
LogicNodeData,
OperatorNodeData,
VariableNodeData,
LiteralNodeData,
StructureNodeData,
StructureElement,
CellData,
ConversionResult,
StructuredError,
TracedResult,
OperatorCategory,
FlowDirection,
IconName,
} from '@goplasmatic/datalogic-ui';
Constants
import { OPERATORS, CATEGORY_COLORS } from '@goplasmatic/datalogic-ui';
OPERATORS: the operator registry, keyed by name. Each entry carries its label, category, arity, properties-panel configuration, and help (summary, return type, notes, and examples). The registry covers every operator the bundled engine accepts, and the package’s test suite evaluates every help example against the engine, so an example that disagrees with the engine fails the tests.
CATEGORY_COLORS: a per-category palette for consumer-side legends,
pickers and custom node renderers. The shipped nodes are not coloured by
category: they are coloured by the type of value they produce, through the
--sig-* tokens described in Customization.
Utilities and hooks
import {
jsonLogicToNodes,
applyTreeLayout,
useWasmEvaluator,
DataLogicEvaluationError,
summarizeEvaluationConfig,
isDefaultEvaluationConfig,
} from '@goplasmatic/datalogic-ui';
jsonLogicToNodes and applyTreeLayout are described under
Utility Functions.
useWasmEvaluator: the engine hook the component itself uses. It loads
the bundled WASM engine and builds one Engine per combination of the
templating flag, the config settings and the set of customOperators
names:
const { ready, loading, error, evaluate, evaluateMetered, evaluateWithTrace } = useWasmEvaluator({
templating: false,
config: { preset: 'strict' },
customOperators: { double: (args) => Number(args[0]) * 2 },
});
if (ready) {
const result = evaluate({ '+': [1, 2] }, {}); // 3
const trace = evaluateWithTrace({ '+': [1, 2] }, {}); // { result, steps, expression_tree, pointers, ... }
const { value, ops } = evaluateMetered({ '+': [1, 2] }, {}); // 3, plus the operations charged
}
error holds the message when the engine fails to load. The component shows
the same message in a banner when data asks for evaluation.
The traced result (TracedResult) carries result, expression_tree and
steps; error and structured_error when evaluation fails; and pointers:
for each node id, the RFC 6901 JSON Pointer into the rule of the value that
node was compiled from (absent when the rule does not compile).
DataLogicEvaluationError: thrown by evaluate, evaluateMetered and
evaluateWithTrace when the engine fails, and when the engine rejects
config. Its .structured field is a StructuredError carrying type and
message, plus operator, node_ids, thrown, variable, level,
index, length, stage, budget or spent where the engine provides
them.
summarizeEvaluationConfig / isDefaultEvaluationConfig: format a
DataLogicEvaluationConfig as the one-line summary the toolbar shows, and
test whether it matches the engine defaults.
Utility Functions
jsonLogicToNodes
Convert a JSONLogic expression to React Flow nodes and edges.
function jsonLogicToNodes(
expr: JsonLogicValue | null,
options?: { templating?: boolean }
): ConversionResult
interface ConversionResult {
nodes: LogicNode[];
edges: LogicEdge[];
rootId: string | null;
}
Parameters:
expr: JSONLogic expression to convert (nullyields an empty result)options.templating: whentrue, multi-key objects compile to output-shaping templates with embedded JSONLogic
Returns: A ConversionResult with nodes, edges, and rootId (the id of the root node, or null for an empty expression)
Example:
import { jsonLogicToNodes } from '@goplasmatic/datalogic-ui';
const expr = { "==": [{ "var": "x" }, 1] };
const { nodes, edges, rootId } = jsonLogicToNodes(expr);
// nodes: LogicNode[], React Flow nodes whose `data` is
// OperatorNodeData | LiteralNodeData | StructureNodeData (node `type` is
// 'operator' | 'literal' | 'structure'). The `==` and `var` expressions
// become operator nodes (categories 'comparison' and 'variable'); the
// `1` becomes a literal node.
// edges: LogicEdge[], React Flow edges linking each operator to its arguments
// rootId: string, id of the root `==` node
applyTreeLayout
Apply dagre-based tree layout to nodes.
function applyTreeLayout(
nodes: LogicNode[],
edges?: LogicEdge[],
direction?: FlowDirection // 'flow' | 'hierarchy', default 'flow'
): LogicNode[]
Parameters:
nodes: array of nodesedges: optional array of edges. When omitted, the function derives edges from the node relationshipsdirection:'flow'(default) lays the graph out left-to-right in data-flow order: leaf operands on the left, the root’s result on the right.'hierarchy'also runs left-to-right but ranks the root first, matching JSON nesting order. The component’s toolbar toggles between the two and reflects the choice asdata-directionon the.logic-editorroot.
Returns: Nodes with updated positions and dimensions.
Advanced Usage
Custom Node Rendering
For advanced customization, use the utilities to render with your own React Flow setup. The editor’s bundled React Flow styles apply only inside .logic-editor, so import @xyflow/react/dist/style.css for your own canvas (see Customization):
import { ReactFlow } from '@xyflow/react';
import { jsonLogicToNodes, applyTreeLayout } from '@goplasmatic/datalogic-ui';
function CustomEditor({ expression }) {
const { nodes: rawNodes, edges } = jsonLogicToNodes(expression);
const nodes = applyTreeLayout(rawNodes, edges);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={customNodeTypes}
// Custom configuration...
/>
);
}
Accessing Category Colors
import { CATEGORY_COLORS } from '@goplasmatic/datalogic-ui';
// Use in your own legends, pickers or custom node renderers
const logicalColor = CATEGORY_COLORS.logical; // '#8b5cf6'
To re-theme the shipped nodes, override the --sig-* tokens instead: see
Customization.
Next Steps
- Customization: theming and styling options
Customization
This guide covers theming, styling, and advanced customization of the DataLogicEditor.
Theming
System Theme (Default)
By default, the editor follows the system theme preference:
<DataLogicEditor value={expression} />
Explicit Theme
Override with the theme prop:
// Always dark
<DataLogicEditor value={expression} theme="dark" />
// Always light
<DataLogicEditor value={expression} theme="light" />
Theme Resolution
The component sets data-theme on its own .logic-editor root element based on the theme prop (or system preference when the prop is omitted). It ignores data-theme on a parent or ancestor element, so wrapping the editor in <div data-theme="dark"> has no effect. To force a theme, use the theme prop:
<DataLogicEditor value={expression} theme="dark" />
Dynamic Theme Switching
function ThemedEditor() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
return (
<div>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
<DataLogicEditor value={expression} theme={theme} />
</div>
);
}
CSS Customization
Container Styling
Use the className prop for container styling:
<DataLogicEditor value={expression} className="custom-editor" />
.custom-editor {
border: 2px solid #3b82f6;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
CSS Variables
The component’s theme variables are scoped to its .logic-editor root element (not :root), so they do not leak into the rest of your app. To override them, target the same scope. The dark theme applies through .logic-editor[data-theme="dark"].
The primary axis is the signal palette: a node is coloured by the type of value it produces, not by its operator category. Everything else sits on a neutral substrate, and the accent colour is reserved for selection, root and focus. These are the token names with their light-theme values:
.logic-editor {
/* Signal palette: colour = the value that flows out of a node.
Each has a matching --sig-*-bg used for fills. */
--sig-bool-true: #1a7f37;
--sig-bool-false: #cf222e;
--sig-bool-rest: #57708a; /* boolean-valued, not yet evaluated */
--sig-number: #0959c0;
--sig-string: #8a5a00;
--sig-collection: #8250df; /* arrays and objects */
--sig-data: #1b7c83; /* var / val / exists: the data tap */
--sig-temporal: #bf3989; /* datetimes and durations */
--sig-null: #6e7781;
/* Substrate */
--board: #eef1f5; /* canvas */
--board-grid: rgba(20, 40, 70, 0.05);
--surface: #ffffff; /* node bodies, panels */
--surface-2: #f6f8fb;
--chip: #ffffff;
--hairline: #d6dde6;
--hairline-2: #e6ebf1;
/* Ink */
--ink: #0e1826;
--ink-2: #33475e;
--muted: #5b6a7d;
--faint: #9aa9ba; /* non-text only: idle wires, dot grid */
/* Structural accent: selection, root, focus ring */
--accent: #4b56d6;
--accent-soft: #e7e9fb;
--accent-hover: #3a44c0;
/* Type */
--font-ui: 'Space Grotesk', ui-sans-serif, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', 'Cascadia Code',
'Consolas', monospace;
/* Shape, elevation, motion */
--radius-sm: 7px; --radius-md: 10px; --radius-lg: 14px;
--shadow-sm: 0 1px 2px rgba(16, 30, 54, 0.05);
--shadow-md: 0 1px 2px rgba(16, 30, 54, 0.06), 0 2px 6px rgba(16, 30, 54, 0.06);
--shadow-lg: 0 8px 30px rgba(16, 30, 54, 0.14), 0 2px 8px rgba(16, 30, 54, 0.08);
--motion-fast: 120ms; --motion-base: 180ms; --motion-slow: 260ms;
}
The dark theme redefines the same tokens under
.logic-editor[data-theme="dark"] (for example --board: #0a0f16,
--surface: #10161f, --ink: #e6edf5).
The stylesheet keeps older token names (--bg-primary, --bg-secondary,
--text-primary, --border-primary, --accent-blue, --node-bg,
--syntax-*, --debug-*, and the --success-* / --error-* / --warning-*
families) as aliases mapped onto the tokens above, so existing overrides keep
working. Prefer the tokens above for new work.
Fonts
The default stacks name Space Grotesk and JetBrains Mono, but the package does not ship the font files. Either install them yourself:
npm install @fontsource/space-grotesk @fontsource/jetbrains-mono
import '@fontsource/space-grotesk';
import '@fontsource/jetbrains-mono';
or point the two tokens at fonts you already load:
.logic-editor {
--font-ui: 'Inter', system-ui, sans-serif;
--font-mono: 'Fira Code', ui-monospace, monospace;
}
Without either step the stacks fall back to the system UI and monospace fonts.
Node Styling
Target specific node types. Prefix each selector with .logic-editor: the
editor scopes its own React Flow rules the same way, so your overrides reach
only the editor and leave other React Flow canvases on the page alone:
/* All nodes */
.logic-editor .react-flow__node {
font-family: 'Inter', sans-serif;
}
/* Operator nodes (and, or, if, var, val, ==, +, etc.) */
.logic-editor .react-flow__node-operator {
border-width: 2px;
}
/* Literal nodes (strings, numbers, booleans, null) */
.logic-editor .react-flow__node-literal {
font-weight: bold;
}
/* Structure nodes (JSON objects/arrays in templating mode) */
.logic-editor .react-flow__node-structure {
font-style: italic;
}
There are three node types: operator, literal, and structure. Variables
(var / val) render as operator nodes, so a .react-flow__node-variable
selector matches nothing.
Edge Styling
Customize connection lines. The editor’s own edge rule is
.logic-editor .react-flow__edge-path, so use the same selector and load your
stylesheet after @goplasmatic/datalogic-ui/styles.css:
.logic-editor .react-flow__edge-path {
stroke: #6b7280;
stroke-width: 2px;
}
.logic-editor .react-flow__edge.selected .react-flow__edge-path {
stroke: #3b82f6;
}
Layout Customization
Container Dimensions
The editor fills its parent, so give the parent a height:
// Fixed height
<div style={{ height: '500px' }}>
<DataLogicEditor value={expression} />
</div>
// Viewport height
<div style={{ height: '100vh' }}>
<DataLogicEditor value={expression} />
</div>
// Flexbox
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<header>...</header>
<div style={{ flex: 1 }}>
<DataLogicEditor value={expression} />
</div>
</div>
Using Utilities
Custom Flow Rendering
For complete control, use the utility functions with your own React Flow
instance. The package’s styles.css styles React Flow only inside
.logic-editor, so a canvas of your own needs React Flow’s stylesheet:
import '@xyflow/react/dist/style.css';
import { ReactFlow, Background, Controls } from '@xyflow/react';
import { jsonLogicToNodes, applyTreeLayout } from '@goplasmatic/datalogic-ui';
function CustomEditor({ expression }) {
const { nodes: rawNodes, edges } = jsonLogicToNodes(expression);
const nodes = applyTreeLayout(rawNodes, edges);
return (
<ReactFlow
nodes={nodes}
edges={edges}
fitView
nodesDraggable={false}
nodesConnectable={false}
>
<Background />
<Controls />
</ReactFlow>
);
}
The nodes carry the types operator, literal and structure. Without a
nodeTypes map for them, React Flow draws its default node and logs a
warning; the next section shows a custom node component.
Custom Node Types
Create custom node components:
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
import { CATEGORY_COLORS, type OperatorNodeData } from '@goplasmatic/datalogic-ui';
function CustomOperatorNode({ data }: NodeProps<Node<OperatorNodeData>>) {
const color = CATEGORY_COLORS[data.category];
return (
<div
style={{
background: color,
padding: '12px 20px',
borderRadius: '8px',
color: 'white',
}}
>
<Handle type="target" position={Position.Top} />
<div>{data.label}</div>
<div style={{ fontSize: '0.75em', opacity: 0.8 }}>{data.operator}</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
const customNodeTypes = {
operator: CustomOperatorNode,
// ... other custom types
};
Node data describes the expression, not its value: there is no result field
on any node shape. Evaluated values live in the trace steps
(evaluateWithTrace), keyed by the engine’s node ids, and the trace’s
pointers map each of those ids to a JSON Pointer into the rule. A custom
renderer that shows results keeps its own map from those pointers to its
nodes.
Category Colors
CATEGORY_COLORS is a palette for your own legends, pickers and custom node
renderers. The shipped nodes do not use it: they are coloured by the value
type they produce, through the --sig-* tokens above. Its keys are the
operator categories plus literal:
import { CATEGORY_COLORS } from '@goplasmatic/datalogic-ui';
// Default colors
console.log(CATEGORY_COLORS);
// {
// variable: '#6366f1',
// comparison: '#14b8a6',
// logical: '#8b5cf6',
// arithmetic: '#22c55e',
// string: '#06b6d4',
// array: '#7c3aed',
// object: '#a855f7',
// control: '#f59e0b',
// datetime: '#0ea5e9',
// validation: '#94a3b8',
// utility: '#64748b',
// error: '#ef4444',
// flagd: '#f97316',
// tensor: '#db2777',
// literal: '#64748b'
// }
// Use in custom components
function Legend() {
return (
<div>
{Object.entries(CATEGORY_COLORS).map(([category, color]) => (
<div key={category} style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ background: color, width: 16, height: 16 }} />
<span>{category}</span>
</div>
))}
</div>
);
}
Responsive Design
Size the container per breakpoint:
function ResponsiveEditor({ expression }) {
return (
<div className="editor-wrapper">
<DataLogicEditor value={expression} />
</div>
);
}
.editor-wrapper {
width: 100%;
height: 300px;
}
@media (min-width: 768px) {
.editor-wrapper {
height: 500px;
}
}
@media (min-width: 1024px) {
.editor-wrapper {
height: 700px;
}
}
Performance Tips
Memoization
The editor treats each new value object as a new rule (unless it is the
echo of its own onChange): the canvas remounts and refits the view. Keep the
expression in state or memoize it so a parent re-render does not pass a fresh
literal:
import { useMemo } from 'react';
function OptimizedEditor({ config }) {
const expression = useMemo(() => ({
"and": [
{ ">=": [{ "var": "age" }, config.minAge] },
{ "var": "active" }
]
}), [config.minAge]);
return <DataLogicEditor value={expression} />;
}
Debounced Data Updates
The trace re-runs when the content of data changes. For data that changes
on every keystroke or animation frame, defer it:
import { useDeferredValue } from 'react';
function DebugWithDeferred({ expression, data }) {
const deferredData = useDeferredValue(data);
return (
<DataLogicEditor
value={expression}
data={deferredData}
/>
);
}
Next Steps
- Props & API: complete reference for
DataLogicEditorProps,useWasmEvaluator, and layout utilities - Usage Modes: combining read-only visualization, step-through debugging, and visual editing
- Quick Start and Installation: setup and common component patterns
- Playground: test custom expressions and themes in the live visual editor
Custom Operators
Extend datalogic-rs with your own operators to implement domain-specific logic.
A custom operator receives its arguments already evaluated, as
&DataValue<'a> borrows, and returns a value allocated in the arena. You
register it on the EngineBuilder; a built engine’s operator set is
fixed. Porting a 4.x operator? See
Migration Guide: custom operators.
The CustomOperator Trait
use bumpalo::Bump;
use datalogic_rs::datavalue::OwnedDataValue;
use datalogic_rs::operator::EvalContext;
use datalogic_rs::{CustomOperatorInfo, DataValue, Diagnostic, Result};
pub trait CustomOperator: Send + Sync {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
ctx: &mut EvalContext<'_, 'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>>;
// Default: `CustomOperatorInfo::opaque()`. See "Declaring what the engine may assume".
fn info(&self) -> CustomOperatorInfo { /* ... */ }
// Default: accepts every call. See "Validating calls before they run".
fn check(&self, args: &[OwnedDataValue]) -> std::result::Result<(), Diagnostic> { /* ... */ }
}
Only evaluate is required. Within 5.x the trait gains default methods
only, so an operator written against 5.0 compiles against every 5.x
release.
| Parameter | What it is |
|---|---|
args | The operator’s arguments already evaluated by the engine. Each &'a DataValue<'a> borrows from caller input or from earlier arena allocations. |
ctx | Opaque view into the engine’s evaluation context. Most operators ignore it. root_input() and depth() are read-only observations for the cases where behaviour depends on the surrounding context, and charge(n) prices work against the operation budget. |
arena | The bumpalo::Bump allocator for the current call. Use arena.alloc(...) for DataValues and arena.alloc_str(...) for strings, or the one-call helpers on ArenaExt. |
The return value must live in the arena (or be a preallocated singleton like
DataValue::Null). Never return a stack reference.
Basic Custom Operator
use bumpalo::Bump;
use datalogic_rs::operator::EvalContext;
use datalogic_rs::{CustomOperator, DataValue, Engine, Error, Result};
struct DoubleOperator;
impl CustomOperator for DoubleOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
let n = args
.first()
.and_then(|v| v.as_f64())
.ok_or_else(|| Error::invalid_arguments("expected number"))?;
Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
}
}
Registering Custom Operators
Operator registration is builder-only. Once you build the engine, its operator set is frozen.
Select your language to see how to register a custom operator:
// Rust
let engine = Engine::builder()
.add_operator("double", DoubleOperator)
.build();
let result = engine.eval_str(r#"{"double": 21}"#, r#"{}"#).unwrap();
assert_eq!(result, "42");
// Node.js (native FFI): pass a { name: fn } map as the second constructor argument
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine({}, {
double: (argsJson) => {
const args = JSON.parse(argsJson);
return JSON.stringify(args[0] * 2);
}
});
// browser/edge: same callback shape via @goplasmatic/datalogic-wasm
// (customOperators constructor option), see the WASM chapter
# Python
from datalogic_py import Engine
import json
engine = Engine(custom_operators={
"double": lambda args_json: json.dumps(json.loads(args_json)[0] * 2)
})
// Go
import (
"encoding/json"
"fmt"
datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
)
engine, err := datalogic.NewEngineBuilder().
AddOperator("double", func(argsJson string) (string, error) {
var args []float64
if err := json.Unmarshal([]byte(argsJson), &args); err != nil {
return "", err
}
return fmt.Sprintf("%g", args[0]*2), nil
}).
Build()
if err != nil {
panic(err)
}
defer engine.Close()
// Java (FFM)
import com.goplasmatic.datalogic.Engine;
// argsJson is a JSON array string; parse with your JSON library (Jackson shown)
try (Engine engine = Engine.builder()
.addOperator("double", argsJson -> {
int n = mapper.readTree(argsJson).get(0).asInt();
return String.valueOf(n * 2);
})
.build()) {
System.out.println(engine.apply("{\"double\": [21]}", "{}")); // "42"
}
// C# / .NET
using Goplasmatic.Datalogic;
using var engine = Engine.Builder()
.AddOperator("double", argsJson =>
{
var n = System.Text.Json.Nodes.JsonNode.Parse(argsJson)![0]!.GetValue<double>();
return (n * 2).ToString();
})
.Build();
Console.WriteLine(engine.Apply("""{"double": [21]}""", "{}")); // "42"
// PHP
use Goplasmatic\Datalogic\Engine;
$engine = Engine::builder()
->addOperator('double', function (string $argsJson): string {
$args = json_decode($argsJson, true);
return (string) ((int) $args[0] * 2);
})
->build();
echo $engine->apply('{"double": [21]}', '{}'); // "42"
Names a built-in already answers to
Built-ins win: add_operator accepts an operator registered as if,
var or any other built-in name (aliases included), and that operator
never runs. try_add_operator refuses such a name instead, with a
ConfigurationError naming the built-in that would win:
let engine = Engine::builder()
.try_add_operator("double", DoubleOperator)? // a free name
.build();
assert!(Engine::builder().try_add_operator("if", DoubleOperator).is_err());
The check follows the builder’s operator families:
only operators of the families the engine has count, so now is a free
name without the datetime feature or with DateTime left out of
with_families. It also refuses a name that begins with the
template key escape,
which a template reads as an output field.
The check reads the builder’s settings when you call it. If you set
with_families or with_template_key_escape after try_add_operator,
build with try_build, which checks every name try_add_operator took
against the final settings (and refuses a max_recursion_depth of 0).
build does neither. check_operator_name(name) gives the same refusal
without registering anything or consuming the builder.
In the bindings the same refusal is an option: strictOperatorNames
(Node, WASM), strict_operator_names (Python), StrictOperatorNames
(Go), withStrictOperatorNames (JVM, PHP) and WithStrictOperatorNames
(.NET).
One operator on several engines
Arc<T> implements CustomOperator for any T: CustomOperator + ?Sized,
Arc<dyn CustomOperator> included, so a host that builds several engines
(one per tenant, say) can keep each operator in an Arc and register a
clone on every builder. The engines share the one instance and its state:
let registry: Vec<(&str, Arc<dyn CustomOperator>)> = vec![
("double", Arc::new(DoubleOperator)),
];
let build = || {
registry
.iter()
.fold(Engine::builder(), |b, (name, op)| b.add_operator(*name, Arc::clone(op)))
.build()
};
let engine = build();
Box<dyn CustomOperator> implements the trait as well, for registries
that hold boxed operators.
Rebuilding an engine
Engine::to_builder() starts a builder from a running engine: the same
custom operators, config, templating mode, template key escape, folding
setting and families. Add or replace what differs and build; the running
engine is untouched. Both engines hold the same operator instances, so a
host that rebuilds on every configuration reload does not register its
operators again:
let running = Engine::builder()
.add_operator("double", DoubleOperator)
.build();
let reloaded = running
.to_builder()
.add_operator("avg", AverageOperator)
.build();
assert!(reloaded.has_custom_operator("double"));
A rule compiled on one engine still evaluates on another: it finds a custom operator by name there. On the engine that compiled it, the call goes straight to the operator’s slot.
Declaring what the engine may assume
By default the engine assumes nothing about a custom operator: it may
return a different result on each call, read the whole data context, and
take any number of arguments. Override info to declare more, as a
CustomOperatorInfo:
use datalogic_rs::CustomOperatorInfo;
impl CustomOperator for DoubleOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
let n = args.first().and_then(|v| v.as_f64()).unwrap_or(0.0);
Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
}
// A function of its one argument alone.
fn info(&self) -> CustomOperatorInfo {
CustomOperatorInfo::pure().with_args(1, Some(1))
}
}
let engine = Engine::builder().add_operator("double", DoubleOperator).build();
// Deterministic, reads no context, constant argument: folded at compile time.
assert!(engine.compile(r#"{"double": 21}"#).unwrap().is_constant());
// Outside the declared count: InvalidArguments before any argument runs.
assert!(engine.eval_str(r#"{"double": [1, 2]}"#, "{}").is_err());
CustomOperatorInfo::opaque() is the default; pure() is deterministic
and reads no context. Chain reading_context() and with_args(min, max)
(max of None for no limit) onto either. The struct is
#[non_exhaustive], so build it through these methods.
What the declaration changes:
- Constant folding. A deterministic operator that does not read the context, called with constant arguments, is evaluated once when the rule compiles and replaced by its result, like a built-in.
- Rule facts.
Logic::facts()trusts it:is_deterministic()holds for a deterministic operator, andreads_complete()for one that does not read the context. - Argument count. A call outside the declared count fails with
InvalidArgumentsbefore any argument is evaluated, andEngine::checkreports it as anArgumentCounterror.
The engine reads the declaration when it compiles a call, so keep it
fixed for the operator’s lifetime. A wrong declaration gives wrong
results: an operator that declares itself deterministic but is not gets
folded to whatever it returned at compile time.
engine.custom_operator_info(name) returns what a registered operator
declares.
Validating calls before they run
Override check to reject a call from its arguments as written.
Engine::check and Engine::compile_checked call it for each call whose
argument count fits info; compile and evaluation do not.
args is the call’s argument list as JSON: {"op": [a, b]} gives
[a, b], and a lone non-array argument {"op": a} gives [a]. An
argument that is an expression arrives as written ({"var": "x"}), so
only a literal can be checked as a value. Return Diagnostic::error for
a call that will fail or Diagnostic::warning for one that runs but
probably not as meant, optionally narrowed with at_argument(i); the
checker fills in the JSON Pointer and the operator name.
use datalogic_rs::datavalue::OwnedDataValue;
use datalogic_rs::{CheckMode, Diagnostic};
struct Table;
impl CustomOperator for Table {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
_arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
Ok(args[0])
}
fn check(&self, args: &[OwnedDataValue]) -> std::result::Result<(), Diagnostic> {
match args.first() {
Some(OwnedDataValue::String(name)) if name != "users" => {
Err(Diagnostic::error(format!("no table {name:?}")).at_argument(0))
}
_ => Ok(()),
}
}
}
let engine = Engine::builder().add_operator("table", Table).build();
let diags = engine.check(r#"{"table": ["orders"]}"#, CheckMode::Engine);
assert_eq!(diags[0].pointer, "/table/0");
The diagnostic carries the code DiagnosticCode::OperatorCheck. See
Rule Analysis for the rest of the checker.
Reading Argument Types
DataValue<'a> is the arena-resident value tree, re-exported from the
datavalue crate. Common accessors:
match args[0] {
DataValue::Null => { /* ... */ }
DataValue::Bool(b) => { /* ... */ }
DataValue::Number(_) => {
let n: Option<f64> = args[0].as_f64();
let i: Option<i64> = args[0].as_i64();
}
DataValue::String(s) => { /* &str */ }
DataValue::Array(items) => { /* &[DataValue<'a>] */ }
DataValue::Object(pairs) => { /* &[(&str, DataValue<'a>)] */ }
_ => {}
}
Example: Average Operator
use bumpalo::Bump;
use datalogic_rs::operator::EvalContext;
use datalogic_rs::{CustomOperator, DataValue, Engine, Result};
struct AverageOperator;
impl CustomOperator for AverageOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
let mut numbers: Vec<f64> = Vec::new();
for av in args {
match av {
DataValue::Array(items) => {
for it in items.iter() {
if let Some(n) = it.as_f64() {
numbers.push(n);
}
}
}
other => {
if let Some(n) = other.as_f64() {
numbers.push(n);
}
}
}
}
if numbers.is_empty() {
return Ok(arena.alloc(DataValue::Null));
}
let avg = numbers.iter().sum::<f64>() / numbers.len() as f64;
Ok(arena.alloc(DataValue::from_f64(avg)))
}
}
let engine = Engine::builder().add_operator("avg", AverageOperator).build();
let result = engine.eval_str(
r#"{"avg": {"var": "scores"}}"#,
r#"{"scores": [80, 90, 85, 95]}"#,
).unwrap();
assert_eq!(result, "87.5");
Example: Range Check Operator
struct InRangeOperator;
impl CustomOperator for InRangeOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
if args.len() != 3 {
return Err(Error::invalid_arguments(
"in_range requires 3 arguments: value, min, max",
));
}
let v = args[0].as_f64()
.ok_or_else(|| Error::invalid_arguments("value must be a number"))?;
let lo = args[1].as_f64()
.ok_or_else(|| Error::invalid_arguments("min must be a number"))?;
let hi = args[2].as_f64()
.ok_or_else(|| Error::invalid_arguments("max must be a number"))?;
Ok(arena.alloc(DataValue::Bool(v >= lo && v <= hi)))
}
}
let engine = Engine::builder()
.add_operator("in_range", InRangeOperator)
.build();
Example: String Formatting Operator
struct FormatOperator;
impl CustomOperator for FormatOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
let template = args
.first()
.and_then(|v| v.as_str())
.ok_or_else(|| Error::invalid_arguments("expected string template"))?;
let mut out = template.to_string();
for av in args.iter().skip(1) {
if let Some(pos) = out.find("{}") {
let replacement = match av {
DataValue::String(s) => (*s).to_string(),
DataValue::Bool(b) => b.to_string(),
DataValue::Null => "null".to_string(),
DataValue::Number(_) => av.as_f64()
.map(|n| n.to_string())
.unwrap_or_default(),
_ => "<value>".to_string(),
};
out.replace_range(pos..pos + 2, &replacement);
}
}
// Allocate the rendered string in the arena and wrap it.
let s = arena.alloc_str(&out);
Ok(arena.alloc(DataValue::String(s)))
}
}
let engine = Engine::builder()
.add_operator("format", FormatOperator)
.build();
let r = engine.eval_str(
r#"{"format": ["Hello, {}! You have {} messages.", {"var": "name"}, {"var": "count"}]}"#,
r#"{"name": "Alice", "count": 5}"#,
).unwrap();
// "Hello, Alice! You have 5 messages."
Thread Safety Requirements
CustomOperator is Send + Sync. For shared mutable state, use the usual
synchronisation primitives:
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
struct CounterOperator { counter: Arc<AtomicUsize> }
impl CustomOperator for CounterOperator {
fn evaluate<'a>(
&self,
_args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst) as i64;
Ok(arena.alloc(DataValue::from_i64(count)))
}
}
Error Handling
Return appropriate errors for invalid inputs:
impl CustomOperator for MyOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
if args.is_empty() {
return Err(Error::invalid_arguments(
"myop requires at least one argument",
));
}
let num = args[0].as_f64().ok_or_else(|| {
Error::type_error(format!("expected number, got {}", value_type_name(args[0])))
})?;
if num < 0.0 {
return Err(Error::custom_message("value must be non-negative"));
}
Ok(arena.alloc(DataValue::from_f64(num.sqrt())))
}
}
fn value_type_name(v: &DataValue<'_>) -> &'static str {
match v {
DataValue::Null => "null",
DataValue::Bool(_) => "boolean",
DataValue::Number(_) => "number",
DataValue::String(_) => "string",
DataValue::Array(_) => "array",
DataValue::Object(_) => "object",
_ => "other",
}
}
Error is structured: code() returns its ErrorCode and tag() the
stable name of that code. When a custom operator returns an error, the
engine fills in operator() with the custom operator’s name, even when it
sits inside built-ins, and node_ids() with the breadcrumb, which
resolve_path(&compiled) turns into a source path.
To wrap a foreign error type into Error, use Error::wrap:
"not_a_number".parse::<i32>().map_err(Error::wrap)?;
// `error.source()` returns the original `ParseIntError`.
Best Practices
- Declare the argument count in
info, so a bad call fails before its arguments run andcheckreports it; validate types early inevaluate. - Allocate results in the arena (
arena.alloc(...)/arena.alloc_str(...)). - Return meaningful errors:
Error::invalid_arguments,Error::type_error,Error::custom_message,Error::wrap. - Keep operators focused: one responsibility per operator.
- Use
Arcfor shared configuration to maintainSend + Sync. - Test with literals, variables, and nested expressions: the engine evaluates each before calling you.
See Also
- Configuration — configure operator families, recursion depth, and strict operator names
- Rule Analysis — how
CustomOperator::infoandcheckintegrate with static analysis - Operation Budget — charge custom operator work with
ctx.charge(n) - Rust API Reference — full signatures for
CustomOperator,EvalContext, andArenaExt
Configuration
You configure evaluation behaviour with EvaluationConfig, and the engine
itself (templating mode, operator families, custom operators) with
EngineBuilder.
Creating a Configured Engine
use datalogic_rs::{Engine, EvaluationConfig, NanHandling};
// Default configuration
let engine = Engine::new();
// Custom configuration
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue);
let engine = Engine::builder().with_config(config).build();
The builder is the only way to configure an engine. The
Migration Guide maps the 4.x constructors
(Engine::with_config and friends) onto it.
Configuration Options
EvaluationConfig is #[non_exhaustive]. Construct it with default()
(or a preset such as safe_arithmetic() / strict()), then chain the
with_* setters:
use datalogic_rs::{EvaluationConfig, NanHandling, DivisionByZeroHandling};
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue)
.with_division_by_zero(DivisionByZeroHandling::ReturnNull)
.with_loose_equality_errors(false);
NaN Handling
Controls what arithmetic does with an argument it cannot coerce to a number.
use datalogic_rs::{EvaluationConfig, NanHandling};
// ThrowError (default), IgnoreValue, CoerceToZero, ReturnNull
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue);
Behavior comparison:
| Setting | {"+": [1, "text", 2]} | {"*": [2, "x", 3]} |
|---|---|---|
ThrowError (default) | Err(Thrown { type: "NaN" }) | Err(Thrown { type: "NaN" }) |
IgnoreValue | 3 (skips "text") | 6 (skips "x") |
CoerceToZero | 3 ("text" becomes 0) | 0 ("x" becomes 0) |
ReturnNull | null | null |
The strings "NaN", "inf" and "infinity", and numeric strings that
overflow f64 ("1e400"), count as non-numbers here: arithmetic applies
this setting to them instead of producing a non-finite number.
Division by Zero
use datalogic_rs::{EvaluationConfig, DivisionByZeroHandling};
// ReturnSaturated (default), ThrowError, ReturnNull, ReturnInfinity
let config = EvaluationConfig::default()
.with_division_by_zero(DivisionByZeroHandling::ThrowError);
Behavior comparison for {"/": [10.5, 0]}:
| Setting | Result |
|---|---|
ReturnSaturated (default) | 1.7976931348623157e308 (f64::MAX, sign of dividend) |
ThrowError | Err(Thrown { type: "NaN" }) |
ReturnNull | null |
ReturnInfinity | f64::INFINITY (sign of dividend) as an OwnedDataValue / DataValue; null on the JSON-string paths |
The setting governs the float path only. An integer dividend over an
integer zero ({"/": [10, 0]}, and likewise {"%": [10, 0]}) raises
Err(Thrown { type: "NaN" }) under every setting, because no in-range
integer sentinel exists; a fractional dividend such as 10.5 takes the
configured path. ReturnInfinity yields the infinite f64 from
Engine::eval / Session::eval, but eval_str (and every language
binding) renders it as null because JSON cannot encode infinity.
Truthiness Evaluation
use std::sync::Arc;
use datalogic_rs::{EvaluationConfig, TruthyEvaluator};
use datalogic_rs::datavalue::OwnedDataValue;
// JavaScript (default), Python, StrictBoolean, Custom
let config = EvaluationConfig::default()
.with_truthy_evaluator(TruthyEvaluator::Python);
// Custom truthy: receives an OwnedDataValue (no serde_json required)
let custom = Arc::new(|value: &OwnedDataValue| -> bool {
value.as_f64().map_or(false, |n| n > 0.0)
});
let config = EvaluationConfig::default()
.with_truthy_evaluator(TruthyEvaluator::Custom(custom));
TruthyEvaluator::custom(f) wraps a closure in the Arc for you.
Truthiness comparison:
| Value | JavaScript | Python | StrictBoolean |
|---|---|---|---|
true | truthy | truthy | truthy |
false | falsy | falsy | falsy |
1 | truthy | truthy | truthy |
0 | falsy | falsy | truthy |
"" | falsy | falsy | truthy |
"0" | truthy | truthy | truthy |
[] | falsy | falsy | truthy |
[0] | truthy | truthy | truthy |
{} | falsy | falsy | truthy |
null | falsy | falsy | falsy |
StrictBoolean treats only null and false as falsy; every other
value, including 0, "", and empty collections, is truthy. Python
differs from JavaScript on one value: NaN (which only arithmetic
produces, never a JSON literal) is falsy in JavaScript and truthy in
Python.
To apply the configured rules to a value you already hold, such as an
evaluated result, call engine.truthy_of(&value) (see the
API Reference) instead of writing
your own check.
Loose Equality Errors
Controls whether loose equality (==) raises errors for incompatible types.
let config = EvaluationConfig::default()
.with_loose_equality_errors(true); // default
Numeric Coercion
NumericCoercionConfig is #[non_exhaustive] too: start from
default() and chain its own with_* setters, then pass it through
with_numeric_coercion.
use datalogic_rs::{EvaluationConfig, NumericCoercionConfig};
let config = EvaluationConfig::default()
.with_numeric_coercion(
NumericCoercionConfig::default()
.with_empty_string_to_zero(false)
.with_null_to_zero(false)
.with_bool_to_number(false)
.with_reject_non_numeric(true),
);
Missing Variables
missing_var chooses what a var / val read that finds nothing
evaluates to. The default, MissingVar::Null, is the JSONLogic rule.
MissingVar::Error raises VariableNotFound naming the path, so a typo
in a path fails instead of flowing on as null:
use datalogic_rs::{Engine, ErrorCode, EvaluationConfig, MissingVar};
let engine = Engine::builder()
.with_config(EvaluationConfig::default().with_missing_var(MissingVar::Error))
.build();
let err = engine.eval_str(r#"{"var": "user.nmae"}"#, r#"{"user": {"name": "ana"}}"#).unwrap_err();
assert_eq!(err.code(), ErrorCode::VariableNotFound);
// Not misses: a default, a present null, and the existence operators.
assert_eq!(engine.eval_str(r#"{"var": ["x", 0]}"#, "{}").unwrap(), "0");
assert_eq!(engine.eval_str(r#"{"var": "a"}"#, r#"{"a": null}"#).unwrap(), "null");
assert_eq!(engine.eval_str(r#"{"missing": ["x"]}"#, "{}").unwrap(), r#"["x"]"#);
Only reads count. A var with a default takes the default, a field that
is present and null is null, missing, missing_some and exists
still answer whether paths exist, and iteration metadata
({"val": [[1], "index"]}) is null outside an iterator. try catches
the error like any other. Under MissingVar::Error the iterator fast
paths that read fields inline step aside, so map, filter and reduce
over a field run slower; with the budget feature they charge the same
operation count either way.
Max Recursion Depth
max_recursion_depth caps how many evaluations may run at once on one
thread, the outermost included. It exists for custom operators that hold
an Arc<Engine> and re-enter it (engine.evaluate(...), a session or a
traced run) from inside their own evaluate. An evaluation that would
pass the cap fails with a ConfigurationError before it starts.
It does not bound how deeply a rule nests (the compiler caps that at 256 levels on every engine), how deep a value gets, or how much work an evaluation does (see Operation Budget). The count is kept per thread across every engine on it; each engine compares it with its own setting. An engine with no custom operators skips the check, since built-ins cannot re-enter the engine.
use datalogic_rs::EvaluationConfig;
// Default is 256: raise it for deeply nested custom-operator graphs,
// lower it to bail sooner.
let config = EvaluationConfig::default()
.with_max_recursion_depth(256);
The value must be at least 1. EvaluationConfig::from_json_str and
EngineBuilder::try_build refuse 0 with a ConfigurationError;
EngineBuilder::build, which cannot fail, keeps it, and no evaluation on
that engine with custom operators can then start.
Operation Budget
Caps the work one evaluation may do (requires feature = "budget").
Where the recursion depth above bounds boundary re-entry, this bounds
the work itself: a map over a large input nested inside another map
is unbounded under the depth cap and bounded under this one.
use datalogic_rs::EvaluationConfig;
// Unbounded by default. `Some(n)` refuses any evaluation that would
// charge more than n operations.
let config = EvaluationConfig::default().with_ops_budget(Some(100_000));
Crossing the ceiling raises ErrorKind::BudgetExceeded { budget, spent }
before the work is done, and a try in the rule cannot recover from
it. The variant exists in every build, so a match on it compiles with or
without the feature; only a budget build raises it. See
Operation Budget for what one operation is, how to
pick a number, and the per-call Engine::evaluate_metered and
Engine::evaluate_metered_report entry points that report what a rule spent.
Configuration Presets
use datalogic_rs::{Engine, EvaluationConfig};
// Lenient: IgnoreValue arithmetic, ReturnNull division by zero,
// loose equality errors off
let engine = Engine::builder()
.with_config(EvaluationConfig::safe_arithmetic())
.build();
// Strict: ThrowError for NaN and division by zero, and no numeric coercion
let engine = Engine::builder()
.with_config(EvaluationConfig::strict())
.build();
Configuring from JSON
EvaluationConfig::from_json_str (requires feature = "serde_json")
builds a configuration from a JSON object. The language bindings pass
engine configuration across their FFI boundaries in this format, through
one shared parser; in Rust code, the typed with_* setters above do the
same job.
All keys are optional. The parser applies the "preset" key first, then
the remaining keys override individual fields on top of it. It rejects
unknown keys and unknown enum strings with a ConfigurationError, so a
typo fails instead of being ignored.
| Key | Value |
|---|---|
preset | "default", "safe_arithmetic", or "strict" |
arithmetic_nan_handling | "throw_error", "ignore_value", "coerce_to_zero", or "return_null" |
division_by_zero | "return_saturated", "throw_error", "return_null", or "return_infinity" |
loose_equality_errors | bool |
truthy_evaluator | "javascript", "python", or "strict_boolean" |
numeric_coercion | object of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric |
max_recursion_depth | integer >= 1 |
ops_budget | integer >= 1, or null for unbounded (budget feature) |
missing_var | "null" or "error" |
Custom truthiness closures (TruthyEvaluator::Custom) cannot be
expressed in JSON; only the Rust API offers them.
From Rust:
use datalogic_rs::{Engine, EvaluationConfig};
let config = EvaluationConfig::from_json_str(r#"{
"preset": "strict",
"division_by_zero": "return_null",
"numeric_coercion": {"null_to_zero": true},
"max_recursion_depth": 64
}"#).unwrap();
let engine = Engine::builder().with_config(config).build();
The same JSON object is what you hand to a binding’s engine constructor. For example, to start from the lenient preset but use strict-boolean truthiness and fail on missing variables:
{
"preset": "safe_arithmetic",
"truthy_evaluator": "strict_boolean",
"missing_var": "error",
"max_recursion_depth": 128
}
Combining with Templating Mode
Use both configuration and templating mode (requires
feature = "templating"):
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::CoerceToZero);
let engine = Engine::builder()
.with_config(config)
.with_templating(true)
.build();
with_templating sets the mode compile uses. To choose the mode for
one rule instead, call engine.compile_template(rule) or
engine.compile_strict(rule): one engine, with one set of custom
operators, can then check conditions strictly and compile output
templates. See
Structured Objects.
Templating mode carries one option of its own,
with_template_key_escape(prefix), unset by default. Without it a
single-key object is always an operator invocation, so a key that names a
built-in (type, map, if, length, …) or a registered custom
operator can never be emitted as an output field. With it, the engine
strips one leading prefix from every template key and never resolves an
escaped key as an operator:
let engine = Engine::builder()
.with_templating(true)
.with_template_key_escape('$')
.build();
// {"$type": {"var": "x"}} -> {"type": 1}
// {"$$type": 1} -> {"$type": 1}
The prefix is a char rather than a fixed $, so payloads that already
use $ keys can choose ~ or # instead. The escape applies to
compile_template too, and try_add_operator refuses a custom operator
whose name begins with it, since such a key is an output field in a
template. Full rules, including the duplicate-key and bare-sigil cases,
are in
Structured Objects.
Operator Families
By default an engine has every operator family the build compiled in.
EngineBuilder::with_families keeps it to the JSONLogic core plus the
families you name:
use datalogic_rs::{Engine, Family};
// The JSONLogic core and the string extensions, nothing else.
let engine = Engine::builder().with_families([Family::ExtString]).build();
assert_eq!(engine.eval_str(r#"{"upper": "a"}"#, "null").unwrap(), r#""A""#);
assert!(engine.eval_str(r#"{"sort": [[2, 1]]}"#, "null").is_err()); // ExtArray left out
Family names every family in every build: Core, DateTime,
ExtString, ExtArray, ExtObject, ExtControl, ErrorHandling,
ExtMath, Tensor and Flagd. Family::ALL lists them,
name() gives the name Engine::operators() reports, and
is_compiled() says whether the Cargo feature behind it is on. Core is
always there, named or not, and a family the build did not compile is
absent whatever you pass. The all-operators feature compiles every
family.
A family left out is not there for that engine. Its operator names
compile as unknown operators (an error at evaluation, or up front from
compile_checked and check), as output fields in templating mode, or
as a custom operator registered under that name. Engine::operators(),
builtin_operator_names(), check’s suggestions and try_add_operator
all follow the engine’s families. The set applies when a rule is
compiled: a rule compiled on another engine keeps its operators wherever
it runs.
The bindings take the same family names: families (Node, WASM,
Python), Families (Go), withFamilies (JVM, PHP) and WithFamilies
(.NET). An unknown name is a ConfigurationError.
Changing a Running Engine’s Configuration
Engine::to_builder() returns a builder holding the engine’s custom
operators, config, templating mode, template key escape, folding setting
and families. Change what differs and build a new engine; the running one
is untouched:
use datalogic_rs::{Engine, EvaluationConfig, MissingVar};
let running = Engine::new();
let reloaded = running
.to_builder()
.with_config(EvaluationConfig::default().with_missing_var(MissingVar::Error))
.build();
See Custom Operators for how the two engines share operator instances.
Configuration Examples
Lenient Data Processing
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue)
.with_division_by_zero(DivisionByZeroHandling::ReturnNull);
let engine = Engine::builder().with_config(config).build();
let r = engine.eval_str(
r#"{"+": [1, "not a number", null, 2]}"#,
r#"{}"#,
).unwrap();
// "3" (ignores non-numeric values)
Strict Validation
let engine = Engine::builder()
.with_config(EvaluationConfig::strict())
.build();
let result = engine.eval_str(r#"{"+": [1, null]}"#, r#"{}"#);
// Err(Thrown { type: "NaN" }): strict mode does not coerce null, "",
// true/false, or non-numeric strings such as "abc" to a number.
// Numeric strings still parse, so {"+": [1, "2"]} is 3 even under strict().
Custom Business Logic Truthiness
use std::sync::Arc;
use datalogic_rs::datavalue::OwnedDataValue;
let custom_truthy = Arc::new(|value: &OwnedDataValue| -> bool {
match value {
OwnedDataValue::Bool(b) => *b,
OwnedDataValue::Number(_) => value.as_f64().map_or(false, |n| n > 0.0),
OwnedDataValue::String(s) => !s.is_empty(),
_ => false,
}
});
let config = EvaluationConfig::default()
.with_truthy_evaluator(TruthyEvaluator::Custom(custom_truthy));
let engine = Engine::builder().with_config(config).build();
// {"if": [0, "yes", "no"]} ⇒ "no"
// {"if": [-5, "yes", "no"]} ⇒ "no"
// {"if": [1, "yes", "no"]} ⇒ "yes"
See Also
- Custom Operators — register custom operators and rebuild engines with
to_builder() - Structured Objects (Templating) — emit JSON objects and escape operator names
- Operation Budget — cap and meter per-evaluation work
- Sandboxing Untrusted Rules — recommended configuration for multi-tenant deployments
- Rust API Reference — full
EvaluationConfigandEngineBuildersignatures
Rule Analysis
You can inspect a rule without evaluating it, since 5.8.0:
Engine::checklists every problem the engine can see before the rule runs, andEngine::compile_checkedrefuses a rule that has any error.Logic::factsreports which data paths a compiled rule reads, which operators it calls, and whether its result depends on anything besides its data.Engine::operatorsdescribes every built-in operator the engine evaluates, andoperators.jsonis the same catalogue as a file.
All three read the operator table that drives compilation and dispatch, so they report what the engine does. Every binding exposes them; see In the bindings.
Checking a rule: Engine::check
pub fn check<R: IntoLogic>(&self, rule: R, mode: CheckMode) -> Vec<Diagnostic>;
pub fn compile_checked<R: IntoLogic>(&self, rule: R) -> Result<Logic, CompileError>;
check walks the rule once and returns every problem in rule order,
including those in branches a given input never reaches. compile stops
at the first problem that prevents compiling and leaves a call that cannot
succeed to fail at evaluation; check reports both kinds up front.
use datalogic_rs::{CheckMode, DiagnosticCode, Engine, Severity};
let engine = Engine::new();
let diags = engine.check(
r#"{"if": [{"vr": "user.age"}, {"map": [{"var": "items"}]}, {"!": [true, false]}]}"#,
CheckMode::Engine,
);
for d in &diags {
println!("{d}");
}
// error at /if/0: unknown operator `vr`; did you mean `var`?
// error at /if/1: `map` takes exactly 2 arguments, not 1
// warning at /if/2: `!` reads 0 to 1 argument; the other 1 argument is never evaluated
assert_eq!(diags[0].code, DiagnosticCode::UnknownOperator);
assert_eq!(diags[0].pointer, "/if/0");
assert_eq!(diags[2].severity, Severity::Warning);
Diagnostics
Each Diagnostic carries:
| Field | Meaning |
|---|---|
code | A DiagnosticCode (table below). |
severity | Severity::Error: the rule fails to compile, or fails whenever this part runs. Severity::Warning: it runs, but probably not as meant. |
message | One sentence naming the operator involved. |
pointer | An RFC 6901 JSON Pointer into the rule, to the object or argument at fault. "" is the whole rule. |
operator | The operator (or key) the problem is about, when there is one. |
Its Display form is <severity> at <pointer>: <message>, with / for
the whole rule.
DiagnosticCode | Severity | Reported for |
|---|---|---|
Unparsable | error | The rule is not valid JSON. |
UnknownOperator | error | A single-key object whose key no operator of this engine answers to, with a “did you mean” suggestion one edit away. |
NotAnOperator | error | An object with several keys outside templating mode. |
ArgumentForm | error | and, or or if given one argument instead of an array. |
ArgumentCount | error / warning | An argument count the operator rejects (error, read from the operator table or from a custom operator’s declared count), or arguments the operator never evaluates (warning). |
InvalidTimezone | error | A literal timezone name that is not in the timezone database. |
SimilarToOperator | warning | In a template, an output key one edit away from an operator name. |
Compile | error | Any other reason the compiler rejects the rule, such as nesting deeper than 256 levels. |
OperatorCheck | as returned | A custom operator’s own check rejected the call. |
Two contexts lower what gets reported. Inside an argument the operator
never evaluates, only problems that stop the rule compiling are reported.
Inside a try arm whose errors the try catches, errors are reported as
warnings, since they cannot fail the rule.
CheckMode, Diagnostic, DiagnosticCode, Severity and CompileError
are #[non_exhaustive], so a match on a code needs a wildcard arm.
Check modes
CheckMode chooses the mode a rule is read in:
| Mode | Reads the rule as |
|---|---|
CheckMode::Engine | The engine’s own mode, as compile would. |
CheckMode::Strict | Outside templating mode, as compile_strict would. |
CheckMode::Template | In templating mode, as compile_template would. This mode needs no templating feature. |
let template = r#"{"name": {"var": "user.name"}, "tier": {"uper": {"var": "user.tier"}}}"#;
// Not a rule: several keys outside templating mode.
assert_eq!(engine.check(template, CheckMode::Strict)[0].code, DiagnosticCode::NotAnOperator);
// A valid template, with one suspicious key.
let diags = engine.check(template, CheckMode::Template);
assert_eq!(diags[0].code, DiagnosticCode::SimilarToOperator);
// warning at /tier: `uper` is an output field here; did you mean the operator `upper`?
compile_checked
compile_checked compiles a rule only when check (in the engine’s own
mode) finds no error. Otherwise it returns a CompileError whose
diagnostics lists every problem, warnings included. A rule with only
warnings compiles.
let err = engine.compile_checked(r#"{"and": true}"#).unwrap_err();
assert_eq!(err.diagnostics[0].code, DiagnosticCode::ArgumentForm);
println!("{err}");
// rule has 1 error(s)
// error at /: `and` takes its arguments as an array
// `compile` accepts the same rule, and it fails when it runs.
assert!(engine.compile(r#"{"and": true}"#).is_ok());
CompileError implements std::error::Error. A rule that does not parse
gives one Unparsable diagnostic.
What a rule reads: Logic::facts
pub fn facts(&self) -> Facts;
facts walks the compiled rule once, without evaluating it, and returns:
| Method | Returns |
|---|---|
reads() | &[DataPath]: the data paths read from the root of the input, sorted, without duplicates, and without any path a listed path covers. |
has_computed_reads() | Whether some var / val path, or missing / missing_some / exists path list, is computed at evaluation time. |
reads_complete() | Whether reads() is everything the rule can read: no computed path, and no custom operator that may read the context. |
reads_data() | Whether the result can depend on the data at all. |
operators() | The built-in operators the rule calls, by canonical name (val for var), sorted. The names match OperatorInfo::name. |
custom_operators() | The custom operators the rule calls, sorted. |
is_deterministic() | false when the rule calls now, or a custom operator that does not declare itself deterministic. throw is deterministic. |
use datalogic_rs::Engine;
let engine = Engine::new();
let rule = engine.compile(
r#"{"and": [{">=": [{"var": "user.age"}, 18]},
{"in": [{"var": "user.country"}, ["DE", "FR"]]}]}"#,
)?;
let facts = rule.facts();
let paths: Vec<String> = facts.reads().iter().map(ToString::to_string).collect();
assert_eq!(paths, ["user.age", "user.country"]);
assert!(facts.reads_complete()); // nothing else can be read
assert!(facts.is_deterministic());
With reads_complete() true, evaluating against data pruned to those
paths gives the same result as the whole document, so you can fetch only
those fields from a data store. With is_deterministic() also true,
their values make a cache key for the result.
How to read the answers:
- Reads are root reads. An iterator body reads the current element,
and a
trycatch arm reads the caught error, so those reads are not listed; the iterator’s source is, and it covers them. A level marker that climbs back to the root ({"val": [[1], "rate"]}inside onemap) is listed. - Covered reads are dropped. Reading
userobservesuser.name, so onlyuseris listed. The empty path ({"var": ""}) is the whole data context. - Segments, not dotted strings.
{"var": "a.b"}reads["a", "b"];{"val": "a.b"}reads the single key["a.b"].DataPath’sDisplayjoins segments with dots, so usesegments()when keys may contain them.is_root()tests for the empty path anda.covers(&b)for an ancestor. - Complete or a lower bound. A computed path
(
{"var": {"var": "key"}}) setshas_computed_reads(), and a custom operator can read the whole context throughEvalContext::root_inputunless itsinfosays otherwise. Either makesreads_complete()false, andreads()is then a lower bound. - The compiled rule, after the optimizer. A branch that constant
folding removed is not read, and an operator it folded away is not
listed:
{"if": [true, {"var": "a"}, {"var": "b"}]}reads onlya. An engine builtwith_constant_folding(false)can report more for the same rule; both answers are sound for the rule they describe.
facts() walks the tree on every call; keep the result if you need it
repeatedly. The engine uses the same facts itself: on the engine that
compiled a rule whose reads are complete, an owned, serde_json or
Roots input is brought into the arena only along the paths the rule
reads.
The operator catalogue: Engine::operators
pub fn operators(&self) -> impl Iterator<Item = OperatorInfo>;
operators() yields one OperatorInfo per built-in operator the engine
evaluates: every operator compiled into the build, limited to the
engine’s families. Custom operators
are not included; custom_operator_names() and
custom_operator_info(name) describe those.
| Field | Meaning |
|---|---|
name | Canonical name ("val", "if", "switch"). |
aliases | Other names the compiler accepts ("var", "?:", "match"). |
family | "Core", "DateTime", "ExtString", … (Family::name()). |
feature | The Cargo feature that gates the family, or None for the core. |
min_args, max_args | The argument counts the operator’s row declares: == reads 2 or more (2, None), map exactly 2. min_args is 0 where the row declares no minimum (val, +, cat), and max_args is None where it declares no maximum. |
reads_context | Whether it reads the data context (val, missing, missing_some, exists, fractional). |
effect | "pure", "clock" (now), "throws" (throw) or "catches" (try). |
cost | What its work is proportional to: "node", "bytes", "per_item", "n_log_n", "quadratic" or "elements". |
scoped_arg | The argument that runs under a pushed context frame, if any: ScopedArg::Index(i) (map’s body is Index(1)) or ScopedArg::LastOfMany (try’s catch arm). |
use datalogic_rs::{Engine, ScopedArg};
let engine = Engine::new();
let map = engine.operators().find(|op| op.name == "map").unwrap();
assert_eq!((map.min_args, map.max_args), (2, Some(2)));
assert_eq!(map.scoped_arg, Some(ScopedArg::Index(1)));
// Which operators read the clock? (`now` needs the `datetime` feature.)
let clock: Vec<&str> = engine.operators().filter(|op| op.effect == "clock").map(|op| op.name).collect();
assert_eq!(clock, ["now"]);
OperatorInfo and ScopedArg are #[non_exhaustive].
operators.json
docs/src/operators/operators.json is the
catalogue for a build with every family compiled in, one object per
operator with the fields above (scoped_arg is a number, "last" or
null). A snapshot test renders it from Engine::operators() and fails
when the file is out of date, together with the feature table in the
Operators Overview; regenerate both with:
UPDATE_OPERATORS_JSON=1 cargo test -p datalogic-rs --all-features --test operators_json_test
The bindings’ operators() returns the same entries for the engine at
hand, and the React editor’s registry test checks its picker entries and
argument counts against the file. schemas/operators.v1.json in the
repository is its JSON Schema.
In the bindings
Every binding reads a rule in a mode (engine, strict or template), and
returns diagnostics, facts and the catalogue in the JSON shapes above
(schemas/diagnostics.v1.json, schemas/facts.v1.json,
schemas/operators.v1.json). Facts arrive as
{reads, computed_reads, reads_complete, reads_data, operators, custom_operators, deterministic},
with each read path as its list of segments.
| Binding | Equivalents |
|---|---|
| Node | engine.check(rule, mode?), engine.compileChecked(rule) (throws errorType: "CompileError" with diagnostics), rule.facts(), engine.operators(), as JS values. |
| WASM | engine.check(logic, mode?), engine.compileChecked(logic) (throws an Error named CompileError with diagnostics), rule.facts(), engine.operators(), as JSON strings. |
| Python | engine.check(rule, mode=None), engine.compile_checked(rule) (raises CompileError with .diagnostics), rule.facts(), engine.operators(), as typed dicts. |
| Go | engine.Check(ruleJSON, datalogic.ModeEngine), engine.CompileChecked(ruleJSON) (an *Error with Type "CompileError" and DiagnosticsJSON), rule.Facts(), engine.Operators(). |
| JVM | engine.check(ruleJson, CompileMode.ENGINE), engine.compileChecked(ruleJson) (DatalogicException with diagnosticsJson()), rule.facts(), engine.operators(). |
| .NET | engine.Check(ruleJson, CompileMode.Engine), engine.CompileChecked(ruleJson) (DatalogicException with DiagnosticsJson), rule.Facts(), engine.Operators(). |
| PHP | $engine->check($ruleJson, Native::MODE_ENGINE), $engine->compileChecked($ruleJson) (ParseException with $diagnosticsJson), $rule->facts(), $engine->operators(). |
| C ABI | datalogic_engine_check, datalogic_engine_compile_checked with datalogic_error_diagnostics_json, datalogic_rule_facts, datalogic_engine_operators; check datalogic_abi_minor() >= 1 before calling them. |
The mode argument is a string in Node, WASM and Python ("engine",
"strict", "template") and an enum or constant elsewhere. A custom
operator registered from a binding declares no argument count and no
check, so the checker accepts every call to it.
See Also
- Custom Operators — declare
CustomOperator::infoandcheckin Rust - Configuration — restrict built-in operator families per engine
- Structured Objects (Templating) — check templates with
CheckMode::Template - Sandboxing Untrusted Rules — validate untrusted rules at ingestion with
compile_checked - Rust API Reference — full
Diagnostic,Facts, andOperatorInfotypes
Operation Budget
Requires the budget feature. Off by default; enabled in every
published binding.
The engine has no built-in timeout. A wall-clock one would not be deterministic, would fire after the work is done rather than before, and could not tell you which rule was expensive. The operation budget is a counter the engine increments as it works, and a ceiling it refuses to cross.
use datalogic_rs::{Engine, ErrorCode, EvaluationConfig};
let engine = Engine::builder()
.with_config(EvaluationConfig::default().with_ops_budget(Some(100_000)))
.build();
// A tenant's rule over a tenant's data. Expensive rules are refused,
// not run.
match engine.eval_str(tenant_rule, payload) {
Ok(result) => serve(result),
Err(e) if e.code() == ErrorCode::BudgetExceeded => reject_as_too_expensive(e),
Err(e) => report(e),
}
ErrorKind::BudgetExceeded and ErrorCode::BudgetExceeded are present
in every build, with or without the budget feature, so a library can
match on them without mirroring the feature set of whoever builds the
engine. Only the budget feature raises the error. The variant carries
budget (the ceiling) and spent (what the rule had asked for when it
crossed it).
Why a count and not a timeout
A count is deterministic: the same rule over the same data charges the same number on every machine, so a rule that is accepted in staging is accepted in production, and a rule that is refused is refused for everyone. Wall-clock time depends on the machine, its load, and what else the process is doing.
A count is also charged before the work. Every operator prices what it is about to do and asks for it up front, so the engine refuses a rule that would build a billion-element tensor instead of running it and reporting afterwards.
And the failure is attributable: BudgetExceeded carries the node
breadcrumb like every other engine error, so you can point at the part of
the rule that went over.
A budget bounds work, and work correlates with wall-clock time without bounding it. For a hard time guarantee you still need process-level isolation (see Security and Sandboxing).
What one operation is
“Nodes dispatched at runtime, plus whatever operators charge.”
In detail:
| Charged | Amount |
|---|---|
| Each node the engine dispatches | 1 |
Each item an iterator examines (map, filter, reduce, the quantifiers, sort, …), over an array or an object | 1 per item or key, charged as soon as the source resolves |
| Each item a collection operator copies, examines or compares | 1 per item, charged before the work (see below) |
| Each whole 64 bytes of string a string operator reads | 1 (see below) |
| Each tensor operator | max(elements read, elements produced) |
| A custom operator | whatever it charges via EvalContext::charge |
And what is not charged:
- Literals cost 0. They return before dispatch; they are data the compiler already resolved, not work the rule asked for.
- Constant-folded subtrees cost 0.
{"+": [1, 2]}is a literal by the time evaluation starts. Charging for it would make the count depend on whether folding was enabled. - A CSE-memoised subtree is charged once, on the evaluation that fills the slot.
The collection operators whose work grows with an argument charge for it:
| Operator | Charge on top of its node |
|---|---|
merge | 1 per item of each array argument (scalars and null cost nothing extra) |
in with an array haystack | the haystack’s length, whether or not the needle is found |
missing, missing_some with a path list from data | 1 per path (literal paths cost nothing extra) |
keys, values, entries | 1 per key |
slice of an array with a step other than 1 | 1 per item produced (a contiguous slice borrows its source and costs nothing extra) |
distinct, group_by | 1 per comparison: each item is compared with every value or group kept so far, so n distinct values cost n(n-1)/2 |
sort | n·⌈log₂ n⌉ comparisons |
+, -, *, /, % over one array argument | 1 per item |
==, ===, !=, !==, in, switch comparing two arrays or two objects | 1 per array element, and |a|×|b| per object level (object equality finds each key by scanning the other side), for each level the comparison reaches; containers of different lengths cost nothing extra |
Without these, an accumulator does quadratic work for a linear count:
{"reduce": [{"var": "xs"}, {"merge": [{"var": "accumulator"}, [{"var": "current"}]]}, []]}
copies the whole accumulator on every step, n(n+1)/2 items over n inputs, and is charged for each of them.
String operators charge 1 per whole 64 bytes of string they read, so a string shorter than 64 bytes, which is nearly every string a rule touches, costs nothing extra:
| Operator | Bytes charged |
|---|---|
cat | each piece it appends (an array or object argument is charged its JSON text), plus 1 per item of an array argument |
substr, upper, lower, trim, length, slice of a string | the input string |
in with a string haystack | the haystack |
starts_with, ends_with | the shorter of the string and the prefix or suffix |
split | the input string, plus 1 per part produced |
So a cat accumulator, {"reduce": [xs, {"cat": [{"var": "accumulator"}, …]}, ""]},
is charged for the accumulated string on every step. A unit of 64 bytes
leaves ordinary rules’ counts at their node counts while a megabyte of
string still costs about 16,000.
Not priced beyond their node: field lookups (var, val, exists)
on wide objects, which may scan the object’s keys; the datetime and
flagd parsers; a custom truthiness function, which receives an owned
copy of each value it tests; and a traced run’s per-step context
snapshots.
filter, all, some, none, map, reduce and sort recognise
some predicate and body shapes (a comparison on a field, + on a field
and a literal) and evaluate them inline without dispatching the body. Those fast paths charge what the general path charges for the same
data, so a count does not depend on which shape the compiler recognised,
on the data’s types, on whether the run is traced, or on
EvaluationConfig::missing_var.
Behaviour change in 5.8.0: the fast paths used to charge less, and counts for those shapes rose to match the general path. Re-meter a budget you calibrated on 5.7 or earlier.
Each built-in’s cost class, what its charge grows with, is in the
operator catalogue: Engine::operators() reports cost as "node"
(constant), "bytes", "per_item", "n_log_n", "quadratic" or
"elements". See Rule Analysis.
Picking a number
The count is deterministic for a pinned crate version, not across versions: a new fast path or fold changes what gets dispatched. Budget for the work you want to allow, not for a number you measured.
To calibrate, meter your real rules against your real payloads, take the worst case, and leave generous headroom.
use bumpalo::Bump;
use datalogic_rs::{Engine, Metered};
let engine = Engine::new();
let compiled = engine.compile(rule)?;
let arena = Bump::new();
// `u64::MAX` meters without bounding.
let Metered { value, ops } = engine.evaluate_metered(&compiled, data, &arena, u64::MAX)?;
println!("{ops} operations");
Session::eval_metered is the same thing on a session’s arena, and the
per-call budget argument overrides the engine-wide
EvaluationConfig::ops_budget for that call.
When a host reserves capacity from a shared ledger before an evaluation
and needs the operation count even when evaluation fails with an ordinary
error (e.g. Thrown, VariableNotFound, TypeError), use
Engine::evaluate_metered_report or Session::eval_metered_report. They
return Metered<Result<...>>, pairing ops with either Ok(value) or
Err(error).
The abort is final
BudgetExceeded is not recoverable. Once the counter is past its
ceiling every later charge fails too, and try propagates the error
instead of moving to its next arm:
{"try": [{"map": [{"var": "huge"}, {"var": ""}]}, "fallback"]}
Under an exhausted budget this raises rather than returning
"fallback", so a rule cannot catch its own budget failure and spend the
budget again in a loop.
A budget changes nothing about throw, try or any other error: they
stay catchable.
Reaching it from a binding
Every binding accepts the budget as the ops_budget config key, which
bounds every evaluation that engine performs:
// Node / WASM
const engine = new Engine({ config: { ops_budget: 100_000 } });
engine = Engine(config={"ops_budget": 100_000})
b := datalogic.NewEngineBuilder()
if err := b.SetConfigJSON(`{"ops_budget":100000}`); err != nil { /* ... */ }
Engine engine = Engine.builder().setConfigJson("{\"ops_budget\":100000}").build();
using var engine = Engine.Builder().SetConfigJson("""{"ops_budget":100000}""").Build();
$engine = Engine::builder()->setConfigJson('{"ops_budget":100000}')->build();
Every binding also has per-call metering, which reports the cost alongside the result:
const { result, ops } = engine.evalMetered(rule, data); // Node
const { result, ops } = JSON.parse(engine.evalMetered(rule, data)); // WASM
| Binding | Per-call metering | Returns |
|---|---|---|
| Node | engine.evalMetered(rule, data, budget?), rule.evaluateMetered(data, budget?) | { result, ops }, result as JSON text |
| WASM | engine.evalMetered(..), rule.evaluateMetered(..), session.evaluateMetered(rule, data, budget?) | JSON text {"result": .., "ops": ..} |
| Python | engine.eval_metered(rule, data, budget=None), rule.evaluate_metered(data, budget=None) | (result_json, ops) |
| Go | session.EvaluateMetered(rule, dataJSON, budget) | (string, uint64, error) |
| JVM | session.evaluateMetered(rule, dataJson, budget) | Metered(value, ops) |
| .NET | session.EvaluateMetered(rule, dataJson, budget = 0) | MeteredResult(Value, Ops) |
| PHP | $session->evaluateMetered($rule, $dataJson, $budget = 0) | ['value' => .., 'ops' => ..] |
| C | datalogic_session_evaluate_metered(..., budget, ..., &ops, &err) | status, result and ops |
The budget argument overrides the configured budget for that call. In
JavaScript and Python you leave it out to use the engine’s budget, and a
budget below 1 is refused. Across the C ABI (Go, JVM, .NET, PHP), 0
means the engine’s configured budget, or unbounded when it has none.
In the Studio
The playground exposes the budget under Engine
settings → Operation budget, and reports what each evaluation spent
as an N ops badge on the Result panel, so you can see the cost of a
rule while editing it, not only when it trips a ceiling. Setting a budget
that a rule crosses shows the BudgetExceeded error with its budget
and spent figures and highlights the node that went over.
Pricing a custom operator
The dispatcher charges 1 for the operator node itself, so an operator whose work is bounded by a constant needs no charge. One that walks a large input or builds a large result should price it before allocating:
use datalogic_rs::{CustomOperator, DataValue, Result, operator::EvalContext};
struct Repeat;
impl CustomOperator for Repeat {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
ctx: &mut EvalContext<'_, 'a>,
arena: &'a bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
let text = args.first().and_then(|v| v.as_str()).unwrap_or("");
let times = args.get(1).and_then(|v| v.as_i64()).unwrap_or(0).max(0) as usize;
// Price the output before allocating it.
ctx.charge((text.len() * times) as u64)?;
Ok(arena.alloc(DataValue::String(arena.alloc_str(&text.repeat(times)))))
}
}
charge exists in every build: with the budget feature off it
compiles to Ok(()), so an operator can call it without a cfg of its
own.
Pick a unit that makes the operator’s cost proportional to its data. Elements touched is the usual choice. An operator whose work is not proportional to its data would be under-priced by an unbounded ratio. That is why the built-in tensor family is arithmetic-free: a matmul reads 2n² elements and does n³ multiplies, so pricing it by data moved would make the budget stop measuring anything.
Cost
The feature is a Cargo flag rather than an always-on Option<u64>
because the add-and-compare per dispatched node is measurable. On the
self benchmark’s full suite, with the feature compiled in and no budget
set, the geomean moved from 22.75 to 23.56 ns/op (+3.6%) when the feature
was introduced, measured as paired runs on one machine. Builds without the
feature have no counter and no compare; ErrorKind::BudgetExceeded stays
in the enum but nothing raises it.
See Also
- Sandboxing Untrusted Rules — combine operation budgets with payload size limits and
compile_checked - Configuration — set
ops_budgetonEvaluationConfigor via JSON - Custom Operators — price custom operator work with
ctx.charge(n) - Error Handling Operators — why
trydoes not catchBudgetExceeded - Rust API Reference —
evaluate_meteredandevaluate_metered_reportsignatures
Structured Objects (Templating)
Templating mode turns JSONLogic into a templating engine: an object with several keys becomes an output object, and a key that names no operator becomes an output field.
Requires
feature = "templating". The mode is off by default. Some examples on this page also use operators behind other Rust features:??needsext-controlandlengthneedsext-string. Every language binding ships with all operator features enabled.
Enabling Structure Preservation
use datalogic_rs::Engine;
// Enable templating mode
let engine = Engine::builder().with_templating(true).build();
// Combine with custom configuration
let engine = Engine::builder()
.with_config(my_config)
.with_templating(true)
.build();
Per compile
You can also choose the mode for one compile, on any engine:
engine.compile_template(rule) compiles in templating mode and
engine.compile_strict(rule) without it, whatever the engine was built
with. The engine’s custom operators, template key escape and folding
setting still apply, so one engine with one set of operator registrations
can compile output templates and strict conditions:
use datalogic_rs::Engine;
let engine = Engine::new(); // built without templating
let template = engine.compile_template(r#"{"user": {"var": "name"}, "source": "api"}"#)?;
let out = engine.session().eval_str(&template, r#"{"name": "ana"}"#)?;
assert_eq!(out, r#"{"user":"ana","source":"api"}"#);
// A condition stays strict: an object with several keys is a compile
// error, and an unknown key is an unknown operator, not an output field.
let condition = engine.compile_strict(r#"{"==": [{"var": "tier"}, "gold"]}"#)?;
assert!(engine.compile_strict(r#"{"tier": "gold", "active": true}"#).is_err());
compile_template needs the templating feature; compile_strict is in
every build. The bindings name the pair compileTemplate /
compileStrict (Python compile_template / compile_strict, Go and .NET
CompileTemplate / CompileStrict). Engine::check takes the same
choice as CheckMode::Template or CheckMode::Strict; see
Rule Analysis.
How It Works
In normal mode, the engine treats an unknown key in a JSON object as an unknown operator (or as a custom operator when one is registered under that name), and an object with several keys as a compile error. With structure preservation enabled, unknown keys become literal output fields.
Normal mode:
{ "user": { "var": "name" } }
// Evaluating fails: Invalid operator: user
Structure preservation mode:
{ "user": { "var": "name" } }
// Result: { "user": "Alice" }
Emitting Keys That Are Operator Names
In templating mode, a single-key object whose key names an operator is an operator call, so the key is swallowed:
{ "type": { "var": "x" } }
// Result: "number" (the `type` operator ran; no key was emitted)
That makes every built-in operator name unusable as an output key on its
own (87 names, aliases included, with every operator feature on): type,
map, filter, if, keys, values, entries, length, in, sort,
now, try, cat, +, ==, and so on. Registered custom operators
shadow keys the same way. A key with siblings is always an output field:
{"type": X, "other": 1} emits both keys.
Opt into an escape prefix to get them back:
use datalogic_rs::Engine;
let engine = Engine::builder()
.with_templating(true)
.with_template_key_escape('$')
.build();
The engine strips exactly one leading prefix from every template key and never resolves an escaped key as an operator:
| Template key | Output key |
|---|---|
$type | type |
$$type | $type |
$$$type | $$type |
$foo | foo |
type | not a key: still the type operator |
{ "$type": { "var": "x" }, "$map": 2, "plain": 3 }
// Result: { "type": 1, "map": 2, "plain": 3 }
Stripping is uniform across arities, so a key means the same thing whether or not it has siblings. Three caveats:
- The setting is off by default. Without it,
$-prefixed keys pass through verbatim. Turning it on changes templates that emit literal$keys: write those as$$key. - Keys can collide after stripping.
{"$a": 1, "a": 2}emits the keyatwice. The engine keeps duplicate pairs (askeys/values/entriesdo), and so does the JSON texteval_strreturns; converting the result to aserde_json::Valuekeeps the last. - A bare sigil strips to the empty key.
{"$": 1}emits{"": 1}.
Choosing the prefix
The prefix is a char, not a fixed $, because $ already begins real
keys in MongoDB documents and JSON Schema output ($ref, $id). If your
payloads are $-heavy, pick something they never use and leave your $
keys untouched:
let engine = Engine::builder()
.with_templating(true)
.with_template_key_escape('~') // `~type` -> `type`; `$ref` untouched
.build();
A custom operator whose name starts with the prefix becomes unreachable while the escape is on (the escape wins). Rename it, or choose a different prefix.
The escape applies to rules compiled in templating mode: on an engine
built with_templating(true), or through compile_template on any engine
that has an escape set. A rule compiled strictly treats every single-key
object as an operator call, so there is nothing to escape into.
Basic Templating
use datalogic_rs::Engine;
let engine = Engine::builder().with_templating(true).build();
let template = r#"{
"greeting": {"cat": ["Hello, ", {"var": "name"}, "!"]},
"isAdmin": {"==": [{"var": "role"}, "admin"]}
}"#;
let data = r#"{"name": "Alice", "role": "admin"}"#;
let result = engine.eval_str(template, data).unwrap();
// {"greeting":"Hello, Alice!","isAdmin":true}
Nested Structures
Structure preservation works at any depth:
let template = r#"{
"user": {
"profile": {
"displayName": {"var": "firstName"},
"email": {"var": "userEmail"},
"verified": true
},
"settings": {
"theme": {"??": [{"var": "preferredTheme"}, "light"]},
"notifications": {"var": "notificationsEnabled"}
}
},
"metadata": {
"version": "1.0"
}
}"#;
let data = r#"{
"firstName": "Bob",
"userEmail": "bob@example.com",
"notificationsEnabled": true
}"#;
let result = engine.eval_str(template, data).unwrap();
Arrays in Templates
The engine processes arrays element by element:
let template = r#"{
"items": [
{"name": "Item 1", "price": {"var": "price1"}},
{"name": "Item 2", "price": {"var": "price2"}}
],
"total": {"+": [{"var": "price1"}, {"var": "price2"}]}
}"#;
let data = r#"{"price1": 10, "price2": 20}"#;
let result = engine.eval_str(template, data).unwrap();
Dynamic Arrays with Map
Generate arrays dynamically using map:
let template = r#"{
"users": {
"map": [
{"var": "userList"},
{
"id": {"var": "id"},
"name": {"var": "name"},
"isActive": {"var": "active"}
}
]
}
}"#;
let data = r#"{
"userList": [
{"id": 1, "name": "Alice", "active": true},
{"id": 2, "name": "Bob", "active": false}
]
}"#;
let result = engine.eval_str(template, data).unwrap();
The preserve Operator Was Removed
v4 had an explicit preserve operator that wrapped a value to prevent
further evaluation. v5 removed it. Templating mode emits objects as
output, and literal scalars and arrays pass through inline. To emit a
JSON object verbatim from a rule, enable with_templating(true) (or use
compile_template) and write the object.
Reading a Compiled Template Back
Logic::to_json() writes a compiled rule or template back as JSON that
compiles to the same rule. Keys, paths, custom operator names and throw
types that hold quotes, backslashes or control characters come out
escaped, escaped template keys keep their prefix ({"$type": ..}), and a
{"val": "a.b"} read (the single key "a.b") stays in the val form
rather than turning into {"var": "a.b"}, which reads a then b. The
output describes the rule after constant folding, so a folded
subexpression appears as its value.
Use Cases
API Response Transformation
let template = r#"{
"success": true,
"data": {
"user": {
"id": {"var": "userId"},
"profile": {
"name": {"cat": [{"var": "firstName"}, " ", {"var": "lastName"}]},
"avatar": {"cat": ["https://cdn.example.com/", {"var": "avatarId"}, ".jpg"]}
}
}
}
}"#;
Document Generation
let template = r#"{
"invoice": {
"number": {"cat": ["INV-", {"var": "invoiceId"}]},
"customer": {
"name": {"var": "customerName"},
"address": {"var": "customerAddress"}
},
"items": {"var": "lineItems"},
"total": {
"reduce": [
{"var": "lineItems"},
{"+": [{"var": "accumulator"}, {"var": "current.amount"}]},
0
]
}
}
}"#;
Configuration Templating
let template = r#"{
"database": {
"host": {"??": [{"var": "DB_HOST"}, "localhost"]},
"port": {"??": [{"var": "DB_PORT"}, 5432]},
"name": {"var": "DB_NAME"},
"ssl": {"==": [{"var": "ENV"}, "production"]}
},
"cache": {
"enabled": {"var": "CACHE_ENABLED"},
"ttl": {"if": [
{"==": [{"var": "ENV"}, "development"]},
60,
3600
]}
}
}"#;
Dynamic Forms
let template = r#"{
"form": {
"title": {"var": "formTitle"},
"fields": {
"map": [
{"var": "fieldDefinitions"},
{
"name": {"var": "name"},
"type": {"var": "type"},
"required": {"var": "required"},
"label": {"cat": [{"var": "name"}, {"if": [{"var": "required"}, " *", ""]}]}
}
]
}
}
}"#;
Mixing Operators and Structure
Operators and literal structure mix at any level:
let template = r#"{
"type": "response",
"version": "2.0",
"status": {"if": [
{"var": "success"},
"ok",
"error"
]},
"data": {"if": [
{"var": "success"},
{
"result": {"var": "data"},
"count": {"length": {"var": "data"}}
},
{
"error": {"var": "errorMessage"},
"code": {"var": "errorCode"}
}
]}
}"#;
See Also
- Configuration — configure
with_templatingandwith_template_key_escape - Rule Analysis — lint templates for misspelled operator names with
CheckMode::Template - Object Operators — inspect object
keys,values, andentries - Use Cases & Examples — templating recipes for validation, API mapping, and reports
Thread Safety
Engine and Logic are Send + Sync: build and compile once, then share
them across threads. A Session owns a mutable arena, so it is Send but
not Sync: give each thread or worker its own.
Thread-Safe Design
Logic is Send + Sync
Logic (the v5 name for CompiledLogic) is Send + Sync. The engine
does not wrap it in Arc for you. Wrap it yourself when you want cheap
cross-thread sharing, or call Engine::compile_arc to do it in one step:
use datalogic_rs::Engine;
use std::sync::Arc;
let engine = Engine::new();
// Manual:
let compiled = Arc::new(
engine.compile(r#"{">": [{"var": "x"}, 10]}"#).unwrap(),
);
// Or in one step (equivalent to `Arc::new(engine.compile(rule)?)`):
let compiled = engine.compile_arc(r#"{">": [{"var": "x"}, 10]}"#).unwrap();
// Cloning the Arc is cheap: it bumps the refcount.
let compiled_clone = Arc::clone(&compiled);
Engine is also Send + Sync once built, so wrap it in Arc the same
way when sharing across threads.
Sharing Across Threads
use datalogic_rs::Engine;
use std::sync::Arc;
use std::thread;
let engine = Arc::new(Engine::new());
let compiled = engine.compile_arc(r#"{"*": [{"var": "x"}, 2]}"#).unwrap();
let handles: Vec<_> = (0..4).map(|i| {
let engine = Arc::clone(&engine);
let compiled = Arc::clone(&compiled);
thread::spawn(move || {
let mut session = engine.session();
session
.eval_str(&compiled, &format!(r#"{{"x": {}}}"#, i))
.unwrap()
})
}).collect();
for handle in handles {
println!("{}", handle.join().unwrap());
}
Sessions That Own Their Engine
engine.session() returns a Session<'_> that borrows the engine, so it
cannot outlive that borrow. A SharedSession holds the engine by Arc
instead. It is 'static + Send: you can store it in a struct field, move
it to another thread, or hold it across an .await. Build one with
SharedSession::new(Arc<Engine>) or SharedSession::from(arc). It has
the same methods as a borrowed session (SharedSession is
Session<'static, Arc<Engine>>), and session.engine() returns the
engine either way.
use std::sync::Arc;
use datalogic_rs::{Engine, Logic, SharedSession};
struct Worker {
session: SharedSession,
rule: Arc<Logic>,
}
impl Worker {
fn new(engine: Arc<Engine>, rule: Arc<Logic>) -> Self {
Self { session: SharedSession::new(engine), rule }
}
fn run(&mut self, payload: &str) -> datalogic_rs::Result<String> {
let out = self.session.eval_str(&self.rule, payload);
self.session.reset();
out
}
}
let engine = Arc::new(Engine::new());
let rule = engine.compile_arc(r#"{">": [{"var": "x"}, 10]}"#).unwrap();
let mut worker = Worker::new(Arc::clone(&engine), rule);
let handle = std::thread::spawn(move || worker.run(r#"{"x": 42}"#).unwrap());
assert_eq!(handle.join().unwrap(), "true");
Async Runtime Integration
With Tokio
Evaluation is CPU-bound, so use spawn_blocking to keep async runtimes
responsive:
use datalogic_rs::Engine;
use std::sync::Arc;
#[tokio::main]
async fn main() {
let engine = Arc::new(Engine::new());
let compiled = engine.compile_arc(r#"{"+": [{"var": "a"}, {"var": "b"}]}"#).unwrap();
let tasks: Vec<_> = (0..10).map(|i| {
let engine = Arc::clone(&engine);
let compiled = Arc::clone(&compiled);
tokio::task::spawn_blocking(move || {
let mut session = engine.session();
let payload = format!(r#"{{"a": {}, "b": {}}}"#, i, i * 2);
session.eval_str(&compiled, &payload)
})
}).collect();
for task in tasks {
let result = task.await.unwrap().unwrap();
println!("{}", result);
}
}
A task that evaluates many small rules between awaits can keep one
SharedSession for its whole lifetime. Each evaluation runs to completion
before the task can yield, and the session moves with the task if the
runtime reschedules it on another thread:
use std::sync::Arc;
use datalogic_rs::{Engine, SharedSession};
let engine = Arc::new(Engine::new());
let rule = engine.compile_arc(r#"{"+": [{"var": "a"}, {"var": "b"}]}"#).unwrap();
let task = tokio::spawn(async move {
let mut session = SharedSession::new(engine);
let mut total = 0;
for i in 0..3 {
tokio::task::yield_now().await; // the session lives across the await
let out = session.eval_str(&rule, &format!(r#"{{"a": {i}, "b": 1}}"#)).unwrap();
total += out.parse::<i64>().unwrap();
session.reset();
}
total
});
assert_eq!(task.await.unwrap(), 6);
Thread Pool Pattern
For high-throughput workloads, use a thread pool. Each worker keeps its own
Session, so it reuses one arena across calls without contention:
use datalogic_rs::Engine;
use rayon::prelude::*;
use std::sync::Arc;
let engine = Arc::new(Engine::new());
let compiled = engine
.compile_arc(r#"{"filter": [{"var": "items"}, {">": [{"var": "value"}, 50]}]}"#)
.unwrap();
let datasets: Vec<String> = (0..1000)
.map(|i| format!(r#"{{"items": [{{"value": {}}}, {{"value": {}}}]}}"#, i % 100, (i + 1) % 100))
.collect();
let results: Vec<_> = datasets
.par_iter()
.map_init(
|| engine.session(),
|session, data| {
let r = session.eval_str(&compiled, data);
session.reset();
r
},
)
.collect();
Tip:
Sessiondoes not reset itself. Callsession.reset()between batches (as above) to keep peak memory tracking the largest single evaluation rather than the lifetime sum.
Shared Engine vs Per-Thread Engine
Shared Engine (Recommended)
Build the engine once with all custom operators, then share via Arc:
use std::sync::Arc;
use datalogic_rs::Engine;
let engine = Arc::new(
Engine::builder()
.add_operator("custom", MyOperator)
.build(),
);
for _ in 0..4 {
let engine = Arc::clone(&engine);
std::thread::spawn(move || {
let mut session = engine.session();
// Use shared engine.
});
}
Per-Thread Engine
An engine per thread duplicates every operator registration and buys
nothing for thread safety. If a thread-local was there to hold an arena
next to the engine a Session borrowed, keep one engine and give each
thread a SharedSession:
use std::cell::RefCell;
use std::sync::{Arc, LazyLock};
use datalogic_rs::{Engine, Logic, SharedSession};
static ENGINE: LazyLock<Arc<Engine>> = LazyLock::new(|| Arc::new(Engine::new()));
thread_local! {
static SESSION: RefCell<SharedSession> =
RefCell::new(SharedSession::new(Arc::clone(&ENGINE)));
}
fn run(rule: &Logic, data: &str) -> datalogic_rs::Result<String> {
SESSION.with_borrow_mut(|session| {
let out = session.eval_str(rule, data);
session.reset();
out
})
}
Build separate engines only when threads need different configurations, templating modes or operator sets.
Custom Operator Thread Safety
CustomOperator requires Send + Sync. For shared mutable state, use the
usual synchronisation primitives:
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};
use datalogic_rs::{CustomOperator, DataValue, Engine, Result};
use datalogic_rs::operator::EvalContext;
struct CounterOperator {
counter: Arc<AtomicUsize>,
}
impl CustomOperator for CounterOperator {
fn evaluate<'a>(
&self,
_args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a datalogic_rs::bumpalo::Bump,
) -> Result<&'a DataValue<'a>> {
let count = self.counter.fetch_add(1, Ordering::SeqCst) as i64;
Ok(arena.alloc(DataValue::from_i64(count)))
}
}
let counter = Arc::new(AtomicUsize::new(0));
let engine = Engine::builder()
.add_operator("count", CounterOperator { counter: Arc::clone(&counter) })
.build();
Re-entering the Engine
A custom operator that holds an Arc<Engine> can evaluate another rule
from inside its own evaluate (through Engine::evaluate, a session or a
traced run). EvaluationConfig::max_recursion_depth (default 256) caps how
many evaluations may be running at once on one thread, counting the
outermost. The count is per thread and shared by every engine on it; each
engine compares it with its own setting. An evaluation that would go past
the cap fails with ConfigurationError before it starts. An engine with
no custom operators skips the check, since built-ins cannot re-enter.
The cap does not bound how deeply a rule nests (the compiler caps that at
256 levels) or how much work an evaluation does (see
Operation Budget). It must be at least 1:
EngineBuilder::try_build and EvaluationConfig::from_json_str refuse 0
with a ConfigurationError, while build(), which cannot fail, keeps it.
Performance Considerations
Compile Once, Evaluate Many
// Good
let compiled = engine.compile(rule).unwrap();
let mut session = engine.session();
for data in datasets {
session.eval_str(&compiled, data)?;
session.reset();
}
// Bad: compiles the rule again on every iteration
for data in datasets {
engine.eval_str(rule, data)?;
}
Reuse the Arena
Session reuses one bumpalo::Bump across calls; you call
session.reset() between batches so peak memory tracks the largest
single evaluation rather than the sum. For zero-copy &DataValue<'a>
results, manage the bumpalo::Bump yourself and call Engine::evaluate.
Short-Circuit Evaluation
and, or, if, ?:, and ?? short-circuit. Put the cheapest
conditions, and those most likely to decide the result, first.
Error Handling in Threads
use datalogic_rs::{Engine, Error};
use std::sync::Arc;
use std::thread;
let engine = Arc::new(Engine::new());
let compiled = engine.compile_arc(r#"{"+": [1, 1]}"#).unwrap();
let handles: Vec<_> = (0..4).map(|_| {
let engine = Arc::clone(&engine);
let compiled = Arc::clone(&compiled);
thread::spawn(move || -> Result<String, Error> {
let mut session = engine.session();
session.eval_str(&compiled, r#"{}"#)
})
}).collect();
for h in handles {
match h.join().expect("thread panicked") {
Ok(value) => println!("{}", value),
Err(e) => eprintln!("error: {} (operator: {:?}, node_ids: {:?})", e, e.operator(), e.node_ids()),
}
}
Error is Send + Sync, so you can return it from a thread or send it
over a channel.
See Also
- Rust Quick Start — choosing between per-call evaluation,
Session, andEngine::evaluate - Rust API Reference — full
Engine,Logic,Session, andSharedSessionsignatures - Custom Operators — writing thread-safe
Send + Synccustom operators - Performance & Benchmarks — throughput characteristics across evaluation tiers
- Go Concurrency & Pooling and Python GIL & Concurrency — concurrency patterns in language bindings
Security and Sandboxing
datalogic-rs targets untrusted rules over trusted data: rules submitted by users, stored in a database, or fetched from an API, evaluated against data your application controls. This page states what that guarantees, what it does not, and how to run untrusted rules safely.
The sandbox model
A compiled rule is pure data. Evaluating it can only:
- read from the input data you pass in,
- compute with the built-in (and any custom) operators, and
- return a value.
A rule cannot:
- execute arbitrary code: there is no
eval, no scripting runtime, no shell out. Operators are a fixed, compiled-in set (plus any custom operators you register in the host language), - perform I/O: no file, network, environment, or clock access, with the
single exception of the
nowoperator (see Determinism below), - reach outside the data you provide:
var/valresolve against the input value and the active iteration scope only, - mutate the input, the engine, or shared state: evaluation takes
&selfand returns a fresh value.
The core crate sets #![forbid(unsafe_code)]. The language
bindings cross an FFI boundary, so “no unsafe code” is a property of the
Rust engine, not of every binding shim.
Determinism
Evaluation is deterministic given the same rule and data, with one
exception: the now operator (and any datetime arithmetic relative to it,
available under the datetime feature) reads the wall clock. If you need
reproducible evaluation, avoid now or pass the current time in as input
data. The other operators, including the flagd fractional bucketing (a
fixed murmurhash3), are pure functions of their arguments.
Resource bounds that exist
| Bound | Default | What it protects |
|---|---|---|
| JSON parse depth | 256 | Parsing a rule or data string cannot overflow the stack. Deeper input is a ParseError. |
| Compile nesting depth | 256 | A programmatically-built rule (IntoLogic from an owned value, which skips the parser) cannot overflow the stack in compile, dispatch, or drop. Exceeding it is a ConfigurationError. |
max_recursion_depth | 256 | Caps how many evaluations may run at once on one thread, counting the outermost. Only a custom operator that holds an Arc<Engine> and evaluates from inside its own evaluate can stack them. Exceeding it is a ConfigurationError. Set with EvaluationConfig::with_max_recursion_depth; try_build refuses 0. Engines without custom operators skip the check. |
reduce accumulator nesting | 1,024 levels | A fold that wraps its accumulator each step ({"reduce": [xs, [{"var": "accumulator"}], null]}) cannot build a value deep enough to overflow the stack when the result is serialised, compared or copied. Passing it is InvalidArguments. |
| Tensor constructor size | 2^28 elements | zeros, full, scatter, rle_expand, one_hot and pad refuse to allocate more than 268,435,456 elements (2 GiB of f64) from the rule’s dimensions. Passing it is InvalidArguments. |
ops_budget | unset | Caps the work one evaluation may do: one operation per dispatched node, one per item an iterator examines, plus what operators charge for the data they move. Off unless you set it. Requires the budget feature (on in every published binding). |
Arena memory grows during a single evaluation and is released when the
arena is dropped (per-call tiers) or reset. In a long-running Session,
call Session::reset() between logical batches so peak memory tracks the
largest single evaluation rather than the cumulative loop.
Bounding the work a rule does
Iteration count and output size are functions of the input data size
and the rule complexity. A map over a large array nested inside
another map does not touch the recursion cap: it is one boundary
call doing N x M items of work.
Set an operation budget to bound that:
use datalogic_rs::{Engine, EvaluationConfig};
let engine = Engine::builder()
.with_config(EvaluationConfig::default().with_ops_budget(Some(100_000)))
.build();
Every binding accepts the same thing as the ops_budget config key.
Crossing the ceiling fails the evaluation with BudgetExceeded before
the work is done, carrying the node breadcrumb, and a try inside the
rule cannot recover from it. The count is deterministic for a pinned
engine version, so a rule that is refused is refused identically on every
machine. See Operation Budget for what one
operation is and how to pick a number.
Belt and braces, in the order that buys the most:
- Bound attacker-controlled input. Cap array lengths and total payload size before evaluating. Iteration and output size scale with the data, so this is the cheapest control and the one that fails earliest.
- Set an operation budget. This control survives a rule designed to be expensive over input you thought was small enough.
- Bound rule complexity. For user-authored rules, cap the serialized
rule size, and lower
max_recursion_depthif your custom operators re-enter the engine.
Checking a rule before you store it
Engine::compile accepts a call to an unknown operator and leaves it to
fail when it runs, so a bad rule can sit in storage until some input
reaches the broken branch. Engine::check reports every problem the
engine can see before the rule runs, in every branch, each with an RFC
6901 JSON Pointer into the rule: unknown operators (with a “did you mean”
suggestion), argument counts an operator rejects, and / or / if
without an argument array, literal timezones that do not exist, and whatever a
custom operator’s own check rejects. Engine::compile_checked compiles
only a rule with no error diagnostic and otherwise returns every
diagnostic in a CompileError. Use it where you accept rules from users.
See Rule Analysis.
A rule that reads a misspelled path evaluates the read to null under the
JSONLogic default. If your rules and data have a known shape, set
EvaluationConfig::missing_var to MissingVar::Error (config key
"missing_var": "error" in the bindings) and a read that finds nothing
fails with VariableNotFound naming the path. A read with a default
({"var": ["x", 0]}), a present null, missing, missing_some and
exists are not misses, and try catches the error.
Panics and the bindings
The engine reports a bad rule or bad input as an Error. For example, a
format_date pattern with a specifier chrono does not know ("%Q") or a
trailing % is InvalidArguments("Invalid date format"), not a panic
inside chrono’s formatter. If a panic does escape the engine, the Node
binding turns it into a thrown error (or a rejected promise) with name /
errorType "InternalError" instead of aborting the process, and the C
ABI that the Go, JVM, .NET and PHP bindings use catches it and returns
DATALOGIC_STATUS_INTERNAL with error tag "InternalError".
A custom-operator callback in those C-ABI bindings must not evaluate on
the session that is running it. Such a call fails with
DATALOGIC_STATUS_INVALID_ARG (and reset / free on that session do
nothing until the callback returns). Open a second session, or evaluate
the rule without a session, for a nested evaluation.
What is still NOT bounded
- Wall-clock time / CPU. An operation budget bounds work, and work correlates with time, but it is not a time limit: there is no built-in timeout or cancellation, and an individual operator’s charge is taken before its work, not during it.
- Work, if you leave
ops_budgetunset. It is off by default; an engine without one iterates whatever array it is given.
For hard wall-clock guarantees, isolate the evaluation. Rust cannot safely abort a thread mid-computation, so a timeout that must interrupt a running evaluation needs process-level isolation (run evaluation in a subprocess or sandbox you can kill). For most workloads a budget plus input bounds is enough and far cheaper; reach for process isolation only when you must survive an adversarial worst case.
Untrusted-rule checklist
- Compile rules with
Engine::compile_checked(or runEngine::check) when you accept them, and reject the ones with error diagnostics (unknown operators, wrong argument counts, malformed JSON, over-depth) before they reach a hot path. - Size-limit the input data (array lengths, total bytes).
- Size-limit the rule text.
- Set an
ops_budget. Meter your real rules against your real payloads to calibrate it, then leave generous headroom. - Decide how
throwshould surface: a thrown error is a normalResult::Err(ErrorCode::Thrown) carrying the thrown value, not a crash. Catch it if user rules are expected to throw. - If you register custom operators, remember they run host-language code with host privileges; treat operator implementations as trusted, even when the rules that call them are not.
- Consider
MissingVar::Errorif a misspelled path should fail instead of reading asnull. - Avoid
now(or pass the time in as data) if you need reproducibility.
Reporting a vulnerability
Please report suspected security issues privately rather than in a public
issue. See SECURITY.md
for the disclosure process.
See Also
- Operation Budget — cap and meter the work a rule can perform
- Rule Analysis — validate rules before storing them with
compile_checkedandfacts - Configuration — configure
max_recursion_depth,missing_var, and operator families - Integration: Express (Node.js) and Integration: Spring Boot (JVM) — end-to-end ingestion and evaluation patterns
Integration: Express (Node.js)
This guide wires @goplasmatic/datalogic-node
into an Express service the way it wants to be used in production:
one engine for the process, rules compiled once and cached,
evaluation per request, with the async tier keeping heavy
evaluations off the event loop.
The running example is a discount service: rules live in a database column as JSONLogic, product/ops people change them without a deploy, and the API applies whichever rule is active.
Install
npm install express @goplasmatic/datalogic-node
The package ships native prebuilds for eight platforms: no Rust toolchain or node-gyp involved.
A rule service: compile once, cache by version
Compiling is the expensive step (microseconds, but you don’t want to pay it per request). Cache compiled rules keyed by their identity, and let a rule update replace the cache entry:
// rules.js
import { Engine } from '@goplasmatic/datalogic-node';
export const engine = new Engine(); // one per process
const cache = new Map(); // ruleId@version -> compiled Rule
export function getRule(row) {
// row: { id, version, logic } from your DB
const key = `${row.id}@${row.version}`;
let rule = cache.get(key);
if (!rule) {
rule = engine.compile(row.logic); // throws on malformed JSON or invalid structure
cache.set(key, rule);
}
return rule;
}
Compiled Rule objects are immutable and safe to share across requests,
so a plain Map is all the machinery you need. If rules churn, swap the
Map for an LRU: compiled rules are cheap to rebuild. (They cannot be
posted to worker_threads; a worker loads the module and keeps its own
cache.)
The endpoint
// app.js
import express from 'express';
import { engine, getRule } from './rules.js';
import { loadActiveDiscountRule } from './db.js';
const app = express();
app.use(express.json());
app.post('/quote', async (req, res, next) => {
try {
const row = await loadActiveDiscountRule();
const rule = getRule(row);
const total = rule.evaluate({ cart: req.body.cart, user: req.user });
res.json({ total });
} catch (err) {
next(err);
}
});
rule.evaluate(data) takes and returns plain JS objects: no
JSON stringify/parse on your side.
Validating rules at ingestion, not at request time
The moment users or admins can author rules, treat rule ingestion like any other untrusted input path:
app.put('/rules/:id', express.json({ limit: '64kb' }), (req, res) => {
let compiled;
try {
// JSON, structure, operator names and argument counts
compiled = engine.compileChecked(req.body.logic);
} catch (err) {
return res.status(422).json({ error: 'invalid rule', diagnostics: err.diagnostics });
}
// run the rule against golden cases before activating it
try {
for (const [input, expected] of req.body.tests ?? []) {
if (JSON.stringify(compiled.evaluate(input)) !== JSON.stringify(expected)) {
return res.status(422).json({ error: 'rule fails its test cases' });
}
}
} catch (err) {
return res.status(422).json({ error: `rule fails its test cases: ${err.message}` });
}
// ...persist row with a bumped version...
res.sendStatus(204);
});
compile rejects malformed JSON and structurally invalid rules (for
example a multi-key object when templating is off), but it does not
verify operator names: { "discuont": [...] } compiles fine and throws
errorType: 'InvalidOperator' only when you evaluate the rule.
compileChecked runs the engine’s check first and refuses the rule
with a CompileError whose diagnostics name each problem and its JSON
Pointer (for the typo above, code UnknownOperator at pointer ""), so
an admin sees the typo when saving the rule.
compiled.facts().reads lists the data paths the rule reads
([['cart', 'total'], ['user', 'tier']]), so ingestion can also refuse
a rule that reads fields the endpoint never supplies. To make a typo in
a path fail instead of reading null, build the engine with
config: { missing_var: 'error' }; such a read then throws
errorType: 'VariableNotFound'.
Two things are doing security work here: the size limit on the body
(a hostile 10 MB rule is safe to compile but not free), and the
check-then-test gate. Evaluation itself is sandboxed: rules have no
I/O, no eval, and can only read the data document you pass.
Keeping big evaluations off the event loop
A typical rule evaluates in under a microsecond, so the sync call is right for most endpoints. For large payloads or batch endpoints, use the async tier. It runs on the libuv thread pool:
app.post('/evaluate-batch', async (req, res, next) => {
try {
const rule = getRule(await loadActiveDiscountRule());
const results = await Promise.all(
req.body.items.map((item) => rule.evaluateStrAsync(JSON.stringify(item)))
);
res.json(results.map((r) => JSON.parse(r)));
} catch (err) {
next(err);
}
});
Letting the frontend preview the same rule
Because every binding runs the same core, you can preview the exact
rule your Express service enforces in the browser with
@goplasmatic/datalogic-wasm, or
render and step-debug it with the
React visual editor. Nothing gets
re-implemented, so what the UI shows matches what the API decides.
Error handling
compile and evaluate throw real Error objects with a stable
errorType tag ("ParseError", "InvalidOperator", "Thrown",
"InvalidArguments", …) and operator, the innermost operator that
failed. compileChecked adds "CompileError", and the typed session
methods (evaluateBool / evaluateInt / evaluateFloat) add
"TypeMismatch" for a result of the wrong type. A panic in the native
engine arrives as "InternalError" instead of taking the process down;
treat it as a server error. Map them in your Express error middleware:
app.use((err, req, res, next) => {
if (err.errorType === 'InternalError') return next(err); // an engine bug: 500
if (err.errorType === 'ParseError') return res.status(422).json({ error: err.message });
if (err.errorType) return res.status(400).json({ error: err.message });
next(err);
});
See the Node.js chapter for the full API surface (sessions, data handles, typed results, batch and async evaluation, tracing, operator names, rule facts).
See Also
- Node.js Native (
@goplasmatic/datalogic-node) — full Node.js API reference - Starter Boilerplates — runnable project templates for every language
- Sandboxing Untrusted Rules — security checklist for user-authored rules
- Rule Analysis —
check,compileChecked, andfactsin detail - Integration: Spring Boot (JVM) — the same architecture in a Java/Spring service
Integration: Spring Boot (JVM)
This guide wires io.github.goplasmatic:datalogic into a
Spring Boot service: the engine as a singleton bean, rules compiled
once and cached, evaluation per request. The binding uses the Java 22+
Foreign Function & Memory API (no JNI/JNA), and every native-handle
type (Engine, Rule, Session, TracedSession, DataHandle) is
AutoCloseable.
The running example: eligibility rules stored as JSONLogic in a database column, changeable by ops without a deployment.
Dependencies
<dependency>
<groupId>io.github.goplasmatic</groupId>
<artifactId>datalogic</artifactId>
<version>5.9.0</version>
</dependency>
The jar bundles the native library for each platform. JDK 22+ is required; run with native access enabled:
--enable-native-access=ALL-UNNAMED
Put the flag in your launch script or JAVA_TOOL_OPTIONS; Spring Boot
needs no other setting. On first start the binding extracts the native
library to ~/.cache/datalogic/native/ (Linux) and reuses it on later
starts; in a container with a read-only home directory it extracts to a
temp directory instead.
The engine as a bean
Engine and compiled Rule objects are thread-safe: build once,
share across the whole application. Sessions are the per-thread tier;
you don’t need them until you have a measured hot loop.
@Configuration
public class DatalogicConfig {
@Bean(destroyMethod = "close")
public Engine datalogicEngine() {
return new Engine();
}
}
A rule cache keyed by version
Compilation is the expensive step (microseconds; pay it once per rule version, not per request):
@Service
public class RuleService {
private final Engine engine;
private final ConcurrentHashMap<String, Rule> cache = new ConcurrentHashMap<>();
public RuleService(Engine engine) {
this.engine = engine;
}
/** row.id() + row.version() identify one immutable rule body. */
public Rule compiled(RuleRow row) {
return cache.computeIfAbsent(
row.id() + "@" + row.version(),
key -> engine.compile(row.logic()));
}
}
Request threads can share compiled rules. If rule churn is
high, evict old versions (Caffeine or a bounded LinkedHashMap). Closing
a rule while a request thread still evaluates it is not supported, so
either close() an evicted rule once no request can hold it, or drop
the reference and let the binding’s Cleaner free the native handle
when the rule becomes unreachable.
The endpoint
@RestController
public class EligibilityController {
private final RuleService rules;
private final RuleRepository repo;
EligibilityController(RuleService rules, RuleRepository repo) {
this.rules = rules;
this.repo = repo;
}
@PostMapping("/eligibility")
public String check(@RequestBody String applicantJson) {
Rule rule = rules.compiled(repo.activeEligibilityRule());
return rule.evaluate(applicantJson); // JSON in, JSON out
}
}
The JVM surface is JSON-string in/out, which fits Spring endpoints
that already hold the request body as JSON. If you’re
mapping through Jackson anyway, serialize once and reuse: for payloads
evaluated repeatedly, parse once into a DataHandle (immutable,
thread-safe) and use the handle-based evaluations to skip the per-call
parse.
Validating rules at ingestion
Treat rule ingestion as untrusted input: bound the size, compile with
compileChecked, and run golden tests before activating.
compileChecked refuses a rule the engine can see will fail (an
unknown operator, with a suggestion one edit away; an argument count
the operator rejects; a timezone that does not exist) and lists every
problem at once, each with a JSON Pointer into the rule that an admin
UI can highlight.
@PostMapping("/rules")
public ResponseEntity<?> saveRule(@RequestBody @Size(max = 65_536) String logic) {
try (Rule candidate = engine.compileChecked(logic)) {
for (GoldenCase c : goldenCases) {
if (!candidate.evaluate(c.input()).equals(c.expected())) {
return ResponseEntity.unprocessableEntity()
.body("rule fails golden case: " + c.name());
}
}
} catch (DatalogicException e) {
// compileChecked: error type "CompileError", diagnostics as a JSON array
String body = e.diagnosticsJson() != null ? e.diagnosticsJson() : e.getMessage();
return ResponseEntity.unprocessableEntity().body(body);
}
// persist with a bumped version...
return ResponseEntity.noContent().build();
}
Evaluation itself is sandboxed (rules have no I/O and can only read
the data document you pass), so ingestion bounds (size limits, golden
tests) are where your review effort belongs. Two more checks fit here:
candidate.facts() lists the data paths a rule reads, so you can
refuse one that reads fields outside the applicant schema, and an
ops_budget in the engine’s config
(Engine.builder().setConfigJson("{\"ops_budget\": 100000}")) caps the
work one evaluation may do, failing with error type BudgetExceeded.
Hot paths: sessions and batch
For a measured hot loop (scoring a stream, evaluating a rule set per
message), open a Session per worker thread (it reuses one native
arena across calls) and use the typed (evaluateBool, evaluateLong,
evaluateDouble, evaluateTruthy) and batch (evaluateBatch,
evaluateMany) entry points to skip JSON result parsing. Patterns and
the full tier table are in the
JVM README;
the JVM chapter covers the basics.
One more thing polyglot teams get for free
The rule your Spring service enforces is the same rule (same bytes, same semantics, same conformance battery) that your React admin UI can render and step-debug with the visual editor, and that a Node or Python service can evaluate with its own binding, so behavior does not drift between them.
See Also
- Java / JVM (
io.github.goplasmatic:datalogic) — full JVM API reference and installation guide - Starter Boilerplates — runnable project templates for every language
- Sandboxing Untrusted Rules — security checklist for user-authored rules
- Rule Analysis —
check,compileChecked, andfactsin detail - Integration: Express (Node.js) — the same architecture in a Node.js/Express service
Use Cases & Examples
JSONLogic recipes for common scenarios. Every rule on this page is plain JSON: you write it once, store it where you store data (a database row, a config file, an API payload), and evaluate it unchanged from any language datalogic-rs has a binding for. Each recipe gives the rule, a sample payload and the result; standard-mode recipes also embed a live widget that runs them on the page. A few recipes build output objects in templating mode, and a note under each one says so. Their results appear in the order the template writes its keys, which is the order the engine emits them for JSON text input. Node.js and Python sort an object’s keys when they convert a native object (rule, data or result), which gives the same JSON value.
Run any of these in your language
The pattern is the same in every language: compile the rule once, then evaluate it against as many payloads as you need.
use datalogic_rs::Engine;
let engine = Engine::new();
let rule = engine
.compile(r#"{"==": [{"var": "user.plan"}, "premium"]}"#)
.unwrap();
let mut session = engine.session();
for payload in payloads {
println!("{}", session.eval_str(&rule, payload).unwrap());
session.reset(); // reset between evaluations to keep memory flat
}
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine();
const rule = engine.compile({ '==': [{ var: 'user.plan' }, 'premium'] });
for (const payload of payloads) {
console.log(rule.evaluate(payload));
}
from datalogic_py import Engine
engine = Engine()
rule = engine.compile({"==": [{"var": "user.plan"}, "premium"]})
for payload in payloads:
print(rule.evaluate(payload))
import datalogic "github.com/GoPlasmatic/datalogic-rs/bindings/go/v5"
engine := datalogic.NewEngine()
defer engine.Close()
rule, _ := engine.Compile(`{"==": [{"var": "user.plan"}, "premium"]}`)
defer rule.Close()
for _, payload := range payloads {
out, _ := rule.Evaluate(payload)
fmt.Println(out)
}
import com.goplasmatic.datalogic.Engine;
import com.goplasmatic.datalogic.Rule;
try (Engine engine = new Engine();
Rule rule = engine.compile("{\"==\": [{\"var\": \"user.plan\"}, \"premium\"]}")) {
for (String payload : payloads) {
System.out.println(rule.evaluate(payload));
}
}
using Goplasmatic.Datalogic;
using var engine = new Engine();
using var rule = engine.Compile("""{"==": [{"var": "user.plan"}, "premium"]}""");
foreach (var payload in payloads)
{
Console.WriteLine(rule.Evaluate(payload));
}
use Goplasmatic\Datalogic\Engine;
$engine = new Engine();
$rule = $engine->compile('{"==": [{"var": "user.plan"}, "premium"]}');
foreach ($payloads as $payload) {
echo $rule->evaluate($payload), "\n";
}
A few operators sit behind Cargo features in the Rust crate (ext-string, datetime); recipes that use them say so. Every language binding ships with all operator features enabled, so outside Rust there is nothing to switch on.
Feature Flags
Control feature availability based on user attributes.
Basic Feature Flag
Feature available to premium users in the US:
{
"and": [
{"==": [{"var": "user.plan"}, "premium"]},
{"==": [{"var": "user.country"}, "US"]}
]
}
Data:
{
"user": {"plan": "premium", "country": "US"}
}
Result: true
Try it:
Percentage Rollout
Enable for 20% of users, bucketing on the numeric user ID modulo 100:
{
"<": [
{ "%": [{ "var": "user.id" }, 100] },
20
]
}
Data:
{"user": {"id": 12345}}
Result: false (12345 % 100 = 45, and 45 is not below the 20 cutoff)
For IDs that are strings, or buckets that must match OpenFeature flagd, use the fractional operator, which hashes the key with MurmurHash3.
Try it:
Beta Access
Enable for beta testers, employees, or users who signed up before a date. ends_with needs the ext-string feature in Rust; every binding has it.
{
"or": [
{ "==": [{ "var": "user.role" }, "beta_tester"] },
{ "ends_with": [{ "var": "user.email" }, "@company.com"] },
{ "<": [{ "var": "user.signup_date" }, "2024-01-01"] }
]
}
Data:
{
"user": {"role": "customer", "email": "sam@company.com", "signup_date": "2024-03-15"}
}
Result: true (the email marks this user as an employee)
Try it:
Dynamic Pricing
Compute a price from quantity, tiers or membership.
Discount by Quantity
20% off from 100 units, 10% off from 50 units, list price below that:
{
"if": [
{ ">=": [{ "var": "quantity" }, 100] },
{ "*": [{ "var": "base_price" }, 0.8] },
{ "if": [
{ ">=": [{ "var": "quantity" }, 50] },
{ "*": [{ "var": "base_price" }, 0.9] },
{ "var": "base_price" }
]}
]
}
Data:
{"quantity": 75, "base_price": 100}
Result: 90 (10% discount)
Try it:
Tiered Pricing
The first 10 units cost $10, the next 40 cost $8, and every unit past 50 costs $6:
{
"+": [
{ "*": [{ "min": [{ "var": "quantity" }, 10] }, 10] },
{ "*": [
{ "max": [{ "-": [{ "min": [{ "var": "quantity" }, 50] }, 10] }, 0] },
8
]},
{ "*": [
{ "max": [{ "-": [{ "var": "quantity" }, 50] }, 0] },
6
]}
]
}
Data:
{"quantity": 75}
Result: 570 (10 units at $10, 40 at $8, 25 at $6)
Try it:
Member Pricing
Members pay the product price minus their personal discount percentage:
{
"if": [
{ "var": "user.is_member" },
{ "*": [
{ "var": "product.price" },
{ "-": [1, { "/": [{ "var": "user.member_discount" }, 100] }] }
]},
{ "var": "product.price" }
]
}
Data:
{
"user": { "is_member": true, "member_discount": 15 },
"product": { "price": 200 }
}
Result: 170 (15% member discount)
Try it:
Form Validation
Validate user input and report what failed.
Required Fields
Report which required fields are absent; the missing call inside the template returns that list:
{
"if": [
{ "missing": ["name", "email", "password"] },
{
"valid": false,
"errors": { "missing": ["name", "email", "password"] }
},
{ "valid": true }
]
}
Data:
{"name": "Ada Lovelace"}
Result: {"valid": false, "errors": ["email", "password"]}
Templating recipe. Multi-key objects such as the
valid/errorsbranch need templating mode. Compile the rule as a template: in Rust,Engine::compile_template(with thetemplatingCargo feature) or an engine built withwith_templating(true); in the bindings,compileTemplate(compile_templatein Python,CompileTemplatein Go and .NET) or thetemplatingengine option. The inline widgets on this page run in standard mode, so paste this pair into the playground and switch on Templating to run it.
Field Constraints
Check email shape, password length and age range, and collect a message for each failed check. length needs the ext-string feature in Rust; every binding has it.
{
"valid": { "and": [
{ "in": ["@", { "var": "email" }] },
{ ">=": [{ "length": { "var": "password" } }, 8] },
{ "and": [
{ ">=": [{ "var": "age" }, 18] },
{ "<=": [{ "var": "age" }, 120] }
]}
]},
"errors": { "filter": [
[
{ "if": [
{ "!": { "in": ["@", { "var": "email" }] } },
"Invalid email format",
null
]},
{ "if": [
{ "<": [{ "length": { "var": "password" } }, 8] },
"Password must be at least 8 characters",
null
]},
{ "if": [
{ "or": [
{ "<": [{ "var": "age" }, 18] },
{ ">": [{ "var": "age" }, 120] }
]},
"Age must be between 18 and 120",
null
]}
],
{ "!==": [{ "var": "" }, null] }
]}
}
Data:
{"email": "ada@example.com", "password": "short", "age": 25}
Result: {"valid": false, "errors": ["Password must be at least 8 characters"]}
Templating recipe. Compile it as a template (see Required Fields), or run it in the playground with Templating switched on.
Conditional Validation
If it is a business account, require a company name:
{
"if": [
{ "and": [
{ "==": [{ "var": "account_type" }, "business"] },
{ "missing": ["company_name"] }
]},
{ "error": "Company name required for business accounts" },
{ "valid": true }
]
}
Data:
{"account_type": "business", "contact_email": "ops@acme.io"}
Result: {"error": "Company name required for business accounts"}
Templating recipe. The
errorandvalidbranches are output objects, so compile the rule as a template (see Required Fields), or run it in the playground with Templating switched on.
Access Control
Determine user permissions.
Role-Based Access
Admins can always act; editors only on resources they own:
{
"or": [
{ "==": [{ "var": "user.role" }, "admin"] },
{ "and": [
{ "==": [{ "var": "user.role" }, "editor"] },
{ "==": [{ "var": "resource.owner_id" }, { "var": "user.id" }] }
]}
]
}
Data:
{
"user": {"role": "editor", "id": 42},
"resource": {"owner_id": 42}
}
Result: true
Try it:
Permission Checking
Check whether the required permission is in the user’s permission list:
{
"in": [
{ "var": "required_permission" },
{ "var": "user.permissions" }
]
}
Data:
{
"user": {
"permissions": ["read", "write", "delete"]
},
"required_permission": "write"
}
Result: true
Try it:
Time-Based Access
Grant access only to permitted users, within allowed hours (9 AM to 6 PM), on a weekday:
{
"and": [
{ "in": ["access_data", { "var": "user.permissions" }] },
{ "and": [
{ ">=": [{ "var": "current_hour" }, 9] },
{ "<": [{ "var": "current_hour" }, 18] }
]},
{ "in": [{ "var": "current_day" }, [1, 2, 3, 4, 5]] }
]
}
Data:
{
"user": {"permissions": ["access_data", "export_reports"]},
"current_hour": 14,
"current_day": 3
}
Result: true
Try it:
Fraud Detection
Score and flag potentially fraudulent transactions.
Risk Scoring
Sum weighted signals: high amount (+30), new account (+25), billing/shipping country mismatch (+20), repeated attempts (+25), night-time hour, before 6 or from 22 (+15). A score above 50 flags the transaction for review:
{
"+": [
{ "if": [{ ">": [{ "var": "amount" }, 1000] }, 30, 0] },
{ "if": [{ "<": [{ "var": "account_age_days" }, 7] }, 25, 0] },
{ "if": [
{ "!=": [{ "var": "billing_country" }, { "var": "shipping_country" }] },
20,
0
]},
{ "if": [{ ">": [{ "var": "attempts_last_hour" }, 3] }, 25, 0] },
{ "if": [
{ "or": [
{ "<": [{ "var": "hour" }, 6] },
{ ">=": [{ "var": "hour" }, 22] }
]},
15,
0
]}
]
}
Data:
{
"amount": 1500,
"account_age_days": 3,
"billing_country": "US",
"shipping_country": "CA",
"attempts_last_hour": 1,
"hour": 14
}
Result: 75 (high amount + new account + different country)
Try it:
Velocity Checks
Flag when any velocity signal crosses its threshold: too many transactions in a short window, too much total volume, or the same card used from too many IPs:
{
"or": [
{ ">": [{ "var": "transactions_last_hour" }, 10] },
{ ">": [{ "var": "total_amount_last_hour" }, 5000] },
{ ">": [{ "var": "unique_ips_last_day" }, 3] }
]
}
Data:
{
"transactions_last_hour": 14,
"total_amount_last_hour": 1200,
"unique_ips_last_day": 2
}
Result: true (more than 10 transactions in the last hour)
Try it:
Data Transformation
Transform and reshape data.
API Response Mapping
Reshape raw records into an API response: rename fields, derive a full name, normalize email case, and compute counts. lower and length need the ext-string feature in Rust; every binding has it.
{
"users": {
"map": [
{ "var": "raw_users" },
{
"id": { "var": "user_id" },
"fullName": { "cat": [{ "var": "first_name" }, " ", { "var": "last_name" }] },
"email": { "lower": { "var": "email" } },
"isActive": { "==": [{ "var": "status" }, "active"] }
}
]
},
"total": { "length": { "var": "raw_users" } },
"activeCount": { "length": {
"filter": [
{ "var": "raw_users" },
{ "==": [{ "var": "status" }, "active"] }
]
}}
}
Data:
{
"raw_users": [
{"user_id": 101, "first_name": "Ada", "last_name": "Lovelace", "email": "Ada@Example.COM", "status": "active"},
{"user_id": 102, "first_name": "Alan", "last_name": "Turing", "email": "Alan.Turing@Example.COM", "status": "inactive"}
]
}
Result: {"users": [{"id": 101, "fullName": "Ada Lovelace", "email": "ada@example.com", "isActive": true}, {"id": 102, "fullName": "Alan Turing", "email": "alan.turing@example.com", "isActive": false}], "total": 2, "activeCount": 1}
Templating recipe. Compile it as a template (see Required Fields), or run it in the playground with Templating switched on.
Report Generation
Build a report object with a computed title, a generation timestamp and summary stats. format_date and now need the datetime feature and length the ext-string feature in Rust; every binding has both.
{
"report": {
"title": { "cat": ["Sales Report - ", { "var": "period" }] },
"generated": { "format_date": [{ "now": [] }, "%Y-%m-%d %H:%M"] },
"summary": {
"totalSales": { "reduce": [
{ "var": "transactions" },
{ "+": [{ "var": "accumulator" }, { "var": "current.amount" }] },
0
]},
"avgTransaction": { "/": [
{ "reduce": [
{ "var": "transactions" },
{ "+": [{ "var": "accumulator" }, { "var": "current.amount" }] },
0
]},
{ "length": { "var": "transactions" } }
]},
"topCategory": { "var": "top_category" }
}
}
}
Data:
{
"period": "Q2 2026",
"top_category": "Electronics",
"transactions": [
{"amount": 1200, "category": "Electronics"},
{"amount": 450, "category": "Home"},
{"amount": 900, "category": "Electronics"}
]
}
Result: {"report": {"title": "Sales Report - Q2 2026", "generated": "2026-07-03 09:41", "summary": {"totalSales": 2550, "avgTransaction": 850, "topCategory": "Electronics"}}} (generated reflects the evaluation timestamp, so it varies run to run)
Templating recipe. Compile it as a template (see Required Fields), or run it in the playground with Templating switched on.
Notification Rules
Determine when and how to send notifications.
Alert Conditions
Route by severity: an error rate above 10 pages someone immediately, above 5 posts a Slack warning, above 1 lands in the email digest, and anything lower sends nothing:
{
"if": [
{ ">": [{ "var": "error_rate" }, 10] },
{ "channel": "pager", "priority": "critical" },
{ "if": [
{ ">": [{ "var": "error_rate" }, 5] },
{ "channel": "slack", "priority": "warning" },
{ "if": [
{ ">": [{ "var": "error_rate" }, 1] },
{ "channel": "email", "priority": "info" },
null
]}
]}
]
}
Data:
{"error_rate": 7.5}
Result: {"channel": "slack", "priority": "warning"}
Templating recipe. The
channel/prioritybranches are output objects, so compile the rule as a template (see Required Fields), or run it in the playground with Templating switched on.
User Preferences
Send only if the user has notifications enabled, subscribes to this notification type, and is outside their quiet hours. Quiet hours can wrap past midnight (22 to 8), so the rule tests one range when the start comes first and two ranges otherwise:
{
"and": [
{ "var": "user.notifications_enabled" },
{ "in": [
{ "var": "notification.type" },
{ "var": "user.enabled_types" }
]},
{ "!": { "if": [
{ "<=": [{ "var": "user.quiet_start" }, { "var": "user.quiet_end" }] },
{ "and": [
{ ">=": [{ "var": "current_hour" }, { "var": "user.quiet_start" }] },
{ "<": [{ "var": "current_hour" }, { "var": "user.quiet_end" }] }
]},
{ "or": [
{ ">=": [{ "var": "current_hour" }, { "var": "user.quiet_start" }] },
{ "<": [{ "var": "current_hour" }, { "var": "user.quiet_end" }] }
]}
]}}
]
}
Data:
{
"user": {
"notifications_enabled": true,
"enabled_types": ["security", "billing"],
"quiet_start": 22,
"quiet_end": 8
},
"notification": {"type": "security"},
"current_hour": 14
}
Result: true (14:00 is outside 22:00 to 08:00; at current_hour 23 or 3 the result is false)
Try it:
Where next
- Open the interactive playground (live Studio) to edit any of these rules (switch on Templating for the templating recipes).
- Read Structured Objects (Templating) for full details on multi-key output templates and key escaping.
- Browse the full operators overview for everything these recipes are built from.
- See production service patterns in Integration: Express (Node.js) and Integration: Spring Boot (JVM).
- See how datalogic-rs compares to other JSONLogic engines.
Performance
This page covers where datalogic-rs spends time, how to benchmark it, and how to keep evaluation fast.
The headline numbers
Geomean execution time across 51 benchmark suites (Apple M2 Pro; median of 3 samples; ratios are pairwise shared-suite geomeans; methodology in the benchmark matrix):
datalogic-rs (native Rust) | 10.3 ns (■) 1x
json-logic-engine (JS, compiled) | 63.3 ns (■■■■■■) 7.0x
json-logic-engine (JS, interpreted) | 234.8 ns (■■■■■■■■■■■■■■■■■■■■■■■) 25.8x
jsonlogic-rs (bestowinc Rust engine) | 264.2 ns (■■■■■■■■■■■■■■■■■■■■■■■■■■) 28.1x
json-logic-js (Reference JS library) | 465.1 ns (■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■) 83.6x
The WASM build under Node measures 900.5 ns geomean (88× native): on Node servers, prefer @goplasmatic/datalogic-node. Reproduce it yourself with cargo run --release -p datalogic-bench --bin compare; positioning against each alternative is on How It Compares.
Performance Characteristics
Compilation vs Evaluation
datalogic-rs uses a two-phase approach:
- Compilation (slower): Parse and optimize the JSONLogic expression
- Evaluation (faster): Execute compiled logic against data
Best practice: compile once, evaluate many times.
use datalogic_rs::Engine;
let engine = Engine::new();
let compiled = engine.compile(rule_json).unwrap();
let mut session = engine.session();
for data in datasets {
session.eval_str(&compiled, data)?;
}
OpCode Dispatch
The compiler resolves each operator name once, so evaluating on the compiling engine does not look an operator up by its string:
- Each of the 84 built-in operators compiles to an
OpCodethat dispatch matches on. - A custom operator call compiles to the operator’s slot on the engine that compiled the rule, and dispatch indexes that slot. A rule calling eight custom operators measured 181 ns per evaluation with the earlier name lookup and 75 ns with slot dispatch. Evaluated on a different engine, the rule finds that engine’s operator by name.
Memory Efficiency
- Arena allocation:
&DataValue<'a>results live in abumpalo::Bumpfor one evaluation. Read-through ops likevarborrow zero-copy from the caller’s input. - Reusable arenas:
Sessionreuses oneBumpacross calls; you callsession.reset()between batches so peak memory tracks the largest single evaluation rather than the sum. - Pre-built literal singletons: trivial literals (
Null,Bool, empty primitives) are static and need no per-call allocation. Arc<Logic>: cheap clone for cross-thread sharing.
Input Projection
Before a rule can read an owned or serde_json input, the engine builds
an arena view of it. When a compiled rule’s reads are all known
(Logic::facts() reports reads_complete()) and none of them is the
whole input, the view covers those paths and nothing else: on the way
down each path, only the object keys it names, and the whole value at its
end. Results are the same either way. Reading one field of an 8 MB
context took 36 ns from an OwnedDataValue and 41 ns from a
serde_json::Value, against 2.96 ms and 15.7 ms for a view of the whole
input; on a 1 KB context projection was 3 to 12 times faster.
Projection applies to &OwnedDataValue, &serde_json::Value and Roots
input, through Engine::evaluate, a Session, the one-shot
Engine::eval* methods and the top-level eval* functions. JSON text and
ParsedData are parsed straight into the arena and see no change. The
engine views the whole input when the rule:
- reads a computed path (
{"var": {"cat": [..]}}), - calls a custom operator that may read the context (the default unless
its
info()declares otherwise; see Custom Operators), - reads the whole input (
{"var": ""}), - runs on an engine other than the one that compiled it, or
- runs traced.
Rule Analysis
covers Logic::facts(). cargo run --release -p datalogic-bench --bin projection reproduces the numbers.
Iterator Fast Paths
Some iterator bodies run as a loop specialised to their shape, reading fields straight from each element instead of pushing a frame and dispatching the body per item:
| Operator | Body shape |
|---|---|
filter, all, some, none | a comparison of the element or one of its fields with a literal ({">": [{"var": "score"}, 50]}), and / or / ! / !! trees of those, a bare field, or in against a list of strings; for filter, also === / !== of a field and a value that does not change per item |
map | a field, or + / - / * of a field and a literal or of two fields |
reduce | + / - / * of current (or current.path) and accumulator, including a reduce over such a map |
sort | a key that is a field of the element |
The predicate shape is chosen once per call, not once per item: over 1,000
items, {"filter": [xs, {">": [{"var": ""}, 500]}]} measured 1.17 µs.
These paths give the results the general path gives, fall back to it when
a value has the wrong type, and under an
operation budget charge what it charges.
They stand aside when the run is traced and on an engine configured with
MissingVar::Error, where a missing field must raise an error that an
inline read cannot.
Benchmarking
Running Benchmarks
The benchmark harness lives in its own dev-only crate, datalogic-bench,
under tools/benchmark/. Its binaries share a common harness; these two
cover most uses:
# Single-engine benchmark (datalogic-rs alone, fast arena path)
cargo run --release -p datalogic-bench --bin self
cargo run --release -p datalogic-bench --bin self -- --all # every suite + JSON report
# Cross-library comparison (only datalogic-rs ships by default; see
# tools/benchmark/README.md for adding more subjects)
cargo run --release -p datalogic-bench --bin compare -- --all
Reports land in tools/benchmark/output/ (gitignored). The projection
binary times a rule that reads a few fields of a large context, with and
without input projection; tools/benchmark/README.md
lists the others.
Creating Custom Benchmarks
use std::time::Instant;
use datalogic_rs::Engine;
fn main() {
let engine = Engine::new();
let compiled = engine.compile(r#"{"==": [{"var": "x"}, 1]}"#).unwrap();
let mut session = engine.session();
let iterations = 100_000;
let start = Instant::now();
for _ in 0..iterations {
let _ = session.eval_str(&compiled, r#"{"x": 1}"#);
}
let elapsed = start.elapsed();
let per_op = elapsed / iterations;
println!("Time per evaluation: {:?}", per_op);
}
For the hottest path, call Engine::evaluate and manage the arena
yourself. The result is a zero-copy &DataValue<'a>, without the deep
clone a Session makes at the boundary.
use bumpalo::Bump;
let arena = Bump::new();
let result = engine.evaluate(&compiled, r#"{"x": 1}"#, &arena).unwrap();
// `result` is `&DataValue<'_>`, borrowed from `arena`.
Optimization Tips
1. Reuse Compiled Rules
// Good
let compiled = engine.compile(rule).unwrap();
for data in datasets {
session.eval_str(&compiled, data)?;
}
// Bad: compiles the rule again on every iteration
for data in datasets {
engine.eval_str(rule, data)?;
}
2. Pick the Right Entry Point
| Caller has on hand | Best entry point |
|---|---|
| JSON strings, no engine config | datalogic_rs::eval_str(rule, data) |
| JSON strings (one-shot via configured engine) | Engine::eval_str(rule, data) |
| JSON strings (many runs) | Session::eval_str(&compiled, data) |
OwnedDataValue (many runs) | Session::eval(&compiled, &owned) → OwnedDataValue |
Typed T from serde_json (feature = "serde_json") | Session::eval_into::<T, _>(&compiled, data) |
| Several separate values the rule reads as one object | Session::eval*(&compiled, &Roots::from([..])) |
A session stored in a struct, moved across threads or held across .await | SharedSession::new(Arc<Engine>) |
| Borrowed result, session-owned arena | Session::eval_borrowed(&compiled, data) |
Hot path, owns the Bump | Engine::evaluate(&compiled, data, &arena) |
3. Short-Circuit Evaluation
and, or, if, ?:, and ?? short-circuit. Put the cheapest check,
or the one most likely to decide the result, first:
{
"and": [
{"var": "isActive"},
{"in": ["admin", {"var": "roles"}]}
]
}
4. Minimize Cloning in Custom Operators
CustomOperator receives args as &DataValue<'a> borrows. Avoid
materialising into owned values unless you need to mutate.
let n = args[0].as_f64().unwrap_or(0.0); // cheap read
Declare what the operator does through CustomOperator::info. A
deterministic operator that does not read the context is folded at compile
time when its arguments are constant, and it leaves the rule eligible for
input projection. See
Custom Operators.
5. Minimize Nested Variable Access
Each path segment is one lookup:
{"var": "user.profile.settings.theme.color"} // five lookups
{"var": "themeColor"} // one lookup
6. Pass Separate Values as Roots
If a rule reads several values your host keeps apart (a payload, its
metadata, the caller’s claims), pass them as Roots instead of merging
them into one object per evaluation. Each root is borrowed and viewed in
place, and projection keeps only the roots the rule reads. Against a
per-evaluation json! merge, Roots measured 7 to 10 times faster for
payloads of 4 to 1,024 fields.
use datalogic_rs::{Engine, Roots};
use serde_json::json;
let engine = Engine::new();
let rule = engine
.compile(r#"{"and": [{"==": [{"var": "metadata.channel"}, "web"]}, {">": [{"var": "data.total"}, 100]}]}"#)
.unwrap();
let data = json!({"total": 120});
let metadata = json!({"channel": "web"});
let roots = Roots::from([("data", &data), ("metadata", &metadata)]);
let mut session = engine.session();
assert_eq!(session.eval_str(&rule, &roots).unwrap(), "true");
Each root may be a &serde_json::Value, &OwnedDataValue, &ParsedData
or &DataValue, mixed freely.
JavaScript / WASM Performance
Compile Once
engine.evalStr(logic, data) parses and compiles the rule on every call.
Compile it once with engine.compile(logic) and evaluate the returned
Rule:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
if (typeof init === 'function') await init();
const engine = new Engine();
const iterations = 10_000;
console.time('one-shot');
for (let i = 0; i < iterations; i++) {
engine.evalStr(logic, data);
}
console.timeEnd('one-shot');
const rule = engine.compile(logic);
console.time('compiled');
for (let i = 0; i < iterations; i++) {
rule.evaluate(data);
}
console.timeEnd('compiled');
For a hot loop, engine.session() reuses one arena across calls, and
new DataHandle(json) parses a payload once for many evaluations. The
CompiledRule class and the free evaluate function are deprecated and
removed in 6.0.
React UI Performance
For the DataLogicEditor component:
- Memoise expressions:
const expression = useMemo(() => ({ ... }), [deps]); - Debounce data changes when debugging: providing
dataenables the debugger, so debounce it to avoid re-evaluating on every keystroke.const debouncedData = useDebouncedValue(data, 200); <DataLogicEditor value={expr} data={debouncedData} /> - Omit
datawhen debugging isn’t needed: without it the component renders the expression as a read-only tree, skipping evaluation.<DataLogicEditor value={expr} />
Profiling
Rust Profiling
# perf (Linux)
cargo build --release
perf record ./target/release/your-binary
perf report
# Instruments (macOS)
cargo instruments --release -t "CPU Profiler"
Tracing for Bottlenecks
Enable the trace feature and call engine.trace().eval_str(...)
to inspect each executed node. Steps carry no timing data (use the
profilers above for that); they tell you which nodes ran, in what order,
and with which context, result, and iteration counts.
#[cfg(feature = "trace")]
{
let run = engine.trace().eval_str(rule, data);
for step in &run.steps {
// step.step_id, step.node_id, step.context, step.result, step.error,
// step.iteration_index, step.iteration_total
}
// run.expression_tree carries the per-node expression text, keyed by
// the same node ids (ExpressionNode { id, expression, children }).
}
Production Recommendations
- Pre-compile all rules at startup
- Use a worker pool with per-worker Sessions for parallel evaluation
- Monitor evaluation latency in production
- Bound untrusted rules and their input data. The engine has no built-in timeout; iteration and output size scale with the input, so cap array lengths and payload size. See Security & Sandboxing for the full guidance.
- Consider rule complexity limits for user-defined logic
use datalogic_rs::{Engine, Logic};
use std::collections::HashMap;
use std::sync::Arc;
struct RuleEngine {
engine: Arc<Engine>,
rules: HashMap<String, Arc<Logic>>,
}
impl RuleEngine {
pub fn new() -> Self {
let engine = Arc::new(Engine::new());
let mut rules = HashMap::new();
for (name, logic) in load_rules() {
let compiled = engine.compile_arc(&logic).unwrap();
rules.insert(name, compiled);
}
Self { engine, rules }
}
pub fn evaluate(&self, rule_name: &str, data: &str) -> datalogic_rs::Result<String> {
let compiled = self.rules.get(rule_name)
.ok_or_else(|| datalogic_rs::Error::custom_message(format!("unknown rule: {rule_name}")))?;
let mut session = self.engine.session();
let result = session.eval_str(compiled, data);
session.reset();
result
}
}
fn load_rules() -> Vec<(String, String)> { Vec::new() }
See Also
- How It Compares — feature and benchmark comparison with other JSONLogic engines
- Thread Safety — sharing
EngineandLogicacross threads and async tasks - Rule Analysis — how
Logic::facts()powers input projection - Operation Budget — cap and meter rule work in production
- Rust API Reference — full
Engine,Session, andRootsAPIs
How It Compares
datalogic-rs is a JSONLogic engine: rules are JSON data, evaluated by one Rust core that ships as eight packages (Rust, Node.js, browser WASM, Python, Go, JVM, .NET and PHP). This page compares it with the alternatives people most often weigh it against. For raw numbers and methodology, see Performance and the benchmark matrix.
| Dimension | datalogic-rs | json-logic-js | json-logic-engine | jsonlogic-rs | GoRules ZEN | CEL |
|---|---|---|---|---|---|---|
| Format | JSONLogic (JSON data) | JSONLogic (defines the standard) | JSONLogic superset | JSONLogic | Proprietary JDM + Zen expression language | CEL expression grammar (not JSON) |
| Languages | One core, official bindings for Rust, Node, WASM, Python, Go, Java, .NET, PHP | JS core; other languages are separate community ports | JS/TS only | Rust (single crate, Python/WASM wrappers exist but stale) | Rust core with several bindings | Go/Java mature, others varying |
| Standard compliance | Passes the official JSONLogic test suite, plus opt-in extensions | The reference implementation | Superset with minor deviations | Passes core suite | Not JSONLogic | Own spec |
| Sandboxing | No eval, no I/O, core forbids unsafe code | No eval | No eval | No eval | Function nodes execute JavaScript (QuickJS) | Non-Turing-complete, strong formal story |
| Tooling | React visual editor, step-through trace debugger, online playground, rule checker (check) | Play page | None official | None official | JDM editor + commercial BRMS | Community playgrounds |
| Extensibility | Custom operators per host language | Custom ops in JS | Custom ops (incl. async) in JS | Limited | Custom nodes | Extension functions per environment |
jsonlogic-rs on crates.io is
bestowinc/json-logic-rs, a
different project despite the near-identical name. This crate is
datalogic-rs. The section below compares the
two.
One engine vs. N ports
JSONLogic promises portability: rules are plain JSON, so any language can evaluate them. In practice json-logic-js is the JavaScript reference, and each other language relies on an independent reimplementation: community ports for Python, PHP, Go, Ruby, Java and more, each with its own maintainer, release cadence and bugs. The ports drift. Truthiness edge cases, type coercion, null handling and error behavior diverge one patch release at a time, and a rule that passes tests in your Node service can evaluate differently in your Python batch job.
datalogic-rs compiles one Rust core into every runtime. The Node addon, the WASM package, the Python wheel, and the Go, Java, .NET and PHP bindings all embed the same engine, and none of them reimplements an operator. Two checks keep the languages in step:
- The same 2,128-case conformance battery (66 suites) runs against the core
in CI, and again through the Node, WASM, Python and Go bindings and the
C ABI that the JVM, .NET and PHP bindings load. A shared scenario suite
(
bindings/scenarios/api.json) checks the API surface in all eight. - The flagd
fractionaloperator is byte-compatible with the canonical Go evaluator’s MurmurHash3 bucketing, so a hash-based percentage rollout puts the same user in the same bucket in every language.
When to choose which
datalogic-rs fits when your rules should be data: stored in a database column, diffed in review, generated by a UI, and evaluated with identical semantics on the client and every backend service. What sets it apart is one engine shared across eight runtimes and an official visual debugger for the standard. Each alternative below is the better pick for the case it describes.
json-logic-js
The reference implementation, and the project that defines the JSONLogic standard. datalogic-rs passes the same official test suite, so existing json-logic-js rules run unchanged.
Choose it when: you only need JavaScript, you want the smallest and longest-used dependency, and the reference engine’s speed is enough at your rule volume. As the standard’s source of truth, it is the default choice for a JS-only stack.
Choose datalogic-rs when: the same rules must also run outside JavaScript, evaluation is hot enough to show up in profiles (the native engine measures about 84x faster across the shared benchmark suites), or you want the visual debugger and playground.
Migrating is close to a package swap; the full mapping is in Coming from json-logic-js.
json-logic-engine
A fast, actively maintained JavaScript engine: its compiled mode is the only non-Rust subject within the same order of magnitude as datalogic-rs in the benchmark matrix.
Choose it when: your stack is JS/TS end to end and you want JS-native ergonomics, above all async custom operators, which datalogic-rs’s synchronous custom-operator callbacks do not offer.
Choose datalogic-rs when: you need one core across many languages, native (non-JS) bindings, closer adherence to the reference semantics, or the visual tooling. Rules that stay inside the shared JSONLogic standard carry over unchanged.
jsonlogic-rs (bestowinc)
The similarly named crate on crates.io, and the comparison people search
for most. jsonlogic-rs is a single-crate Rust implementation of core
JSONLogic with an apply(&Value, &Value) API: there is no compile step, so
every call re-walks the rule JSON. Python and WASM wrappers exist in the
same repository but have not seen recent releases. It is a small
dependency for occasional evaluation of standard rules.
Choose it when: you want a minimal one-function crate, your rules stick to the core standard, and evaluations are infrequent enough that per-call rule walking does not matter.
Choose datalogic-rs when: evaluation is hot (compile-once evaluation
measures about 28x faster geomean on the shared suites), you need the
extended operators (datetime, string, try/throw, flagd), custom
operators, tracing, or any of the non-Rust bindings. Both engines speak
JSONLogic, so rules carry over unchanged; switching is confined to the Rust
call sites, and the quick start shows the
equivalent one-liner.
GoRules ZEN
A business-rules platform built around decision tables and graphs (a proprietary JDM format), with a commercial editor. It solves a bigger problem than expression evaluation.
Choose it when: you want a full BRMS abstraction for business users: decision tables, graph orchestration, and a polished commercial editing experience on top of the engine.
Choose datalogic-rs when: you want a lighter embedding around an open standard, or a stricter sandbox: ZEN’s function nodes run JavaScript (QuickJS), while datalogic-rs runs no code a rule supplies.
CEL
Common Expression Language is the standard safe expression language in the Kubernetes and Envoy ecosystems, with a non-Turing-complete guarantee and a formal spec.
Choose it when: you want an expression grammar (not JSON) and its ecosystem integrations, or you already live in a CEL-native environment such as Kubernetes admission control.
Choose datalogic-rs when: rules should be plain JSON: storable, diffable, and UI-generatable with no grammar or parser to maintain, plus an official visual editor for the people who write the rules.
The numbers
Geomean execution time across 51 benchmark suites (Apple M2 Pro; median of 3 samples; ratios are pairwise shared-suite geomeans; methodology in the benchmark matrix).
datalogic-rs (native Rust) | 10.3 ns (■) 1x
json-logic-engine (JS, compiled) | 63.3 ns (■■■■■■) 7.0x
json-logic-engine (JS, interpreted) | 234.8 ns (■■■■■■■■■■■■■■■■■■■■■■■) 25.8x
jsonlogic-rs (bestowinc Rust engine) | 264.2 ns (■■■■■■■■■■■■■■■■■■■■■■■■■■) 28.1x
json-logic-js (Reference JS library) | 465.1 ns (■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■) 83.6x
The WASM build under Node measures 900.5 ns (88x native), so on Node servers use the native binding; the WASM package is for browsers and edge runtimes.
Proof: the same rule everywhere
One rule, one datum, eight runtimes:
{
"and": [
{">=": [{"var": "age"}, 18]},
{"==": [{"var": "status"}, "active"]}
]
}
With data {"age": 25, "status": "active"}, every binding returns true:
| Runtime | One-line call |
|---|---|
| Rust | datalogic_rs::eval_str(rule, data)? |
| Node.js | apply(rule, data) |
| Browser (WASM) | new Engine().evalStr(rule, data) |
| Python | apply(rule, data) |
| Go | datalogic.Apply(rule, data) |
| Java | engine.apply(rule, data) |
| C# | engine.Apply(rule, data) |
| PHP | $engine->apply($rule, $data) |
All eight calls run the same compiled core. Try the rule live in the playground (live Studio), then pick your language in Installation.
See Also
- Performance & Benchmarks — methodology, input projection, and optimization tips
- Coming from json-logic-js — migrating from the JavaScript reference implementation
- Operators Overview — all 84 built-in operators across 10 families
- Sandboxing Untrusted Rules — safety guarantees and resource bounds
Migration Guide
This page covers the updates in 5.9.0 and 5.8.0 and gives an overview of
the move from v4 to v5. The full v4 to v5 cookbook (every renamed call,
cargo-feature swap, and error-handling update) and the notes for earlier
5.x releases live in MIGRATION.md
at the repo root, which is authoritative.
Upgrading to 5.9
5.9.0 is backwards-compatible with 5.8.x:
- Rust MSRV is now 1.98 (Edition 2024).
- New metered report APIs (
budgetfeature):Engine::evaluate_metered_reportandSession::eval_metered_reportreturnMetered<Result<...>>so callers can readopseven when evaluation fails with an ordinary error (Thrown,VariableNotFound,TypeError). See Operation Budget.
Upgrading to 5.8
5.8.0 adds API (rule checking, rule facts, the operator catalogue,
per-compile templating, MissingVar, Roots, SharedSession and more)
without breaking source compatibility. A rule’s result can change in the
cases below, most of them bug fixes. The
CHANGELOG
has every item with examples.
Errors
Error::operator(), and"operator"in a serialized error, name the innermost failing operator, custom operators included. Before 5.8 it was the root operator unless a deeper site set one, and traced runs always reported the root.format_datewith a format specifier chrono does not know ("%Q") or a trailing%is anInvalidArgumentserror instead of a panic.
Data and comparison
- Only a single-key
{"datetime": ..}or{"timestamp": ..}object is a datetime. A record that has such a field next to others is ordinary data for==,===,in,distinctandtype. - Integers above 2^53 compare, divide and sort exactly in
==,===,<and the other comparisons,/,%andsort; they are no longer rounded or judged equal. sortby a field reads a missing field asnull, so it ties with anullfield.filterwith===/!==againstnulltreats a missing field asnull, as the other iterators did.
Arithmetic and strings
- The strings
"NaN","inf"and"infinity", and numeric literals that overflow, no longer coerce to non-finite numbers: arithmetic applies NaN handling to them,==and<raise an error, andabs,ceilandfloorraiseInvalidArguments. NanHandling::CoerceToZerosubstitutes 0 instead of skipping the value:{"*": [2, "x", 3]}is0, not6.- Variadic
+and*coerce numeric strings as the two-argument forms do. ceilandfloorreturn a float for a result outside thei64range instead of saturating.lowerandupperapply context-sensitive Unicode case rules ("ΟΔΟΣ"lowers to"οδος").typeclassifies strings with the datetime and duration parsers:"password1"is"string"(it was"duration").- A rule that passes an expression where an operator distinguishes a
literal argument (a single array for
+,*,maxandmin, avarpath built withcat) gives the same result with or without constant folding:{"+": [{"if": [true, [1, 2], 0]}]}is3with folding on or off. - A rule compiled on one engine and evaluated on an engine with different number coercion, NaN, division, loose-equality or truthiness settings follows the evaluating engine’s settings; before 5.8, values folded at compile time kept the compiling engine’s.
Logic::to_json()writes a rule that reads back as the same rule: a single array argument stays wrapped ({"max": [[1, 2]]}), a dottedvalpath stays invalform, and strings are escaped.
Limits
- A
reduceaccumulator may nest at most 1,024 levels; deeper is anInvalidArgumentserror instead of a stack overflow. - Tensor constructors (
zeros,full,scatter,rle_expand,one_hot,pad) refuse more than 2^28 elements. fractionalclamps bucket weights toi32::MAX, as flagd does.- Operation counts from
evaluate_meteredrise for iterator shapes that took a fast path, to match the general path.
Deprecations (removed in 6.0)
- WASM: the
CompiledRuleclass and the freeevaluateandevaluateWithTracefunctions. Build anEngineand usecompile,evalStrandevaluateWithTraceon it. - Node and WASM:
evaluateNumber. UseevaluateFloat(orevaluateInt).
If you install the Node package: 5.1.1 through 5.7.1 published
@goplasmatic/datalogic-node without its index.js loader, so
require('@goplasmatic/datalogic-node') failed. 5.8.0 ships it again.
v4 to v5 Migration
v5 is a hard cliff
v5 has no compatibility shim. The pre-release compat feature and
the LegacyApi trait are gone. Plan a single cutover: update Cargo.toml,
run a find-and-replace pass, and re-run your test suite.
Rules and data keep the same JSONLogic format; the changes are in the Rust API.
What changed at a glance
- Type renames.
DataLogic→Engine,CompiledLogic→Logic,Operator→CustomOperator,ArenaValue→DataValue,ArenaContextStack→operator::EvalContext.Evaluatoris gone (args arrive pre-evaluated). - Method renames. The
evaluate_*methods becomeeval_*.evaluate_str→eval_str,evaluate_borrowed→eval_borrowed. Theserde_json::Value-shaped variants (evaluate_json_value,evaluate_owned,evaluate_ref, …) collapse into one typed entry point:engine.eval_into::<T, _, _>(rule, data)(ordatalogic_rs::eval_into::<T, _, _>(...)at the module level), gated onfeature = "serde_json". - Builder construction.
DataLogic::with_config(c)/with_preserve_structure()/with_config_and_structure(c, s)all collapse intoEngine::builder()with.with_config(c)and.with_templating(s)setters. - Compilation accepts more shapes.
engine.compile(rule)takes anyIntoLogic:&str,&String,&OwnedDataValue,OwnedDataValue,&serde_json::Value(gated onserde_json). - Module-level helpers for one-shot calls.
datalogic_rs::eval,datalogic_rs::eval_str,datalogic_rs::eval_intoanddatalogic_rs::compileuse a shared default engine, so a one-shot call needs noEngine. - Sessions are explicit. Reusable arenas live on
Session(engine.session()); the session never auto-resets, so you callsession.reset()between batches. - Trace surface is a session.
engine.trace().eval_str(rule, data)returns aTracedRun<R>withresult: Result<R, Error>plusstepsandexpression_tree. Available onfeature = "trace". The oldTracedResulttype is gone; successful and failed runs share the sameTracedRun<R>shape. - Custom operators take pre-evaluated args. Implementations get
args: &[&'a DataValue<'a>], a&mut EvalContext<'_, 'a>, and a&'a bumpalo::Bump; they return&'a DataValue<'a>. - Operator registration is builder-only.
Engineis immutable afterbuild(). Register every custom operator on theEngineBuilderbefore calling.build(). - Error is structured.
Erroris a struct withkind,operator(),node_ids(),tag()andcode(), plus a stable JSON wire format. Construct viaError::invalid_arguments(...),Error::type_error(...),Error::custom_message(...),Error::wrap(...). preserveoperator removed. Literal scalars and arrays pass through inline; templated objects belong in templating mode (Engine::compile_templatefor one rule, orEngine::builder().with_templating(true).build()for the engine; both needfeature = "templating").- Edition 2024 +
#![forbid(unsafe_code)].
Feature-flag rename
The pre-release compat feature is gone. The replacement is
purpose-named:
| v4 / pre-release feature | v5 feature | What it enables |
|---|---|---|
compat (mixed interop + shims) | serde_json | &serde_json::Value interop and the typed eval_into::<T> paths |
preserve | templating | Templating mode and Engine::builder().with_templating(true) |
trace | trace | engine.trace() (transitively enables serde_json) |
Quick before/after sketch
// v4
use datalogic_rs::DataLogic;
let mut engine = DataLogic::with_config(my_config);
engine.add_operator("double".to_string(), Box::new(MyOp));
let compiled = engine.compile(&rule_value)?;
let result: Value = engine.evaluate_owned(&compiled, data)?;
// v5
use datalogic_rs::Engine;
let engine = Engine::builder()
.with_config(my_config)
.add_operator("double", MyOp)
.build();
let compiled = engine.compile(&rule_value)?; // accepts &Value via `serde_json`
let mut session = engine.session(); // reuse one arena for the compiled logic
let result = session.eval(&compiled, &data_value)?; // OwnedDataValue
let result_str = session.eval_str(&compiled, data_str)?; // String (JSON)
let v: serde_json::Value = session.eval_into(&compiled, &data_value)?; // typed
Custom operators
// v5 (final)
use datalogic_rs::{CustomOperator, DataValue, Engine, Result};
use datalogic_rs::operator::EvalContext;
use bumpalo::Bump;
struct DoubleOperator;
impl CustomOperator for DoubleOperator {
fn evaluate<'a>(
&self,
args: &[&'a DataValue<'a>],
_ctx: &mut EvalContext<'_, 'a>,
arena: &'a Bump,
) -> Result<&'a DataValue<'a>> {
// args are already evaluated; no Evaluator call.
let n = args.first()
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
Ok(arena.alloc(DataValue::from_f64(n * 2.0)))
}
}
let engine = Engine::builder()
.add_operator("double", DoubleOperator)
.build();
Where to look next
- The repo-root
MIGRATION.mdhas the per-call cookbook. - The API Reference covers the v5 methods.
- The Quick Start shows the module-level helpers.
v3 to v4 Migration
If you are moving from v3 straight to v5, skip the v3 to v4 step: nothing
in it applies to this codebase. Read the
v4 to v5 section above and the repo-root
MIGRATION.md.
Getting Help
If you hit a problem during migration:
- Check the API Reference
- Review the FAQ and Troubleshooting Guide
- Review the examples
- Open an issue on GitHub
Coming from json-logic-js
json-logic-js is the reference JSONLogic implementation. datalogic-rs passes the same official JSONLogic test suite, so your existing rules run unchanged. What changes is the call surface and a few defaults, listed below. See How It Compares for the wider comparison.
The one-liner
json-logic-js:
import jsonLogic from 'json-logic-js';
jsonLogic.apply({ ">": [{ var: "age" }, 18] }, { age: 21 }); // true
datalogic-rs (Node, native binding):
import { apply } from '@goplasmatic/datalogic-node';
apply({ ">": [{ var: "age" }, 18] }, { age: 21 }); // true
datalogic-rs (browser / WASM): the WASM binding takes and returns JSON text.
import init, { Engine } from '@goplasmatic/datalogic-wasm';
await init();
const engine = new Engine();
engine.evalStr('{">": [{"var": "age"}, 18]}', '{"age": 21}'); // "true"
All three return the same result. To evaluate one rule many times, compile
it once with engine.compile(rule) and call evaluate on the returned
Rule, instead of calling a one-shot in a loop.
Custom operations
json-logic-js registers operations globally:
jsonLogic.add_operation("double", (a) => a * 2);
datalogic-rs registers them per engine, and the callback works in JSON (evaluated arguments as a JSON-array string, result as a JSON string):
import { Engine } from '@goplasmatic/datalogic-node';
const engine = new Engine({}, {
double: (argsJson) => String(JSON.parse(argsJson)[0] * 2),
});
Each binding’s chapter has the exact callback shape. Name a custom
operator after a built-in and it never runs; strictOperatorNames: true
in the engine options turns that into a ConfigurationError.
Behavioral differences to know
datalogic-rs’s defaults are stricter than json-logic-js’s in a few places, and most are configurable. The ones you are most likely to notice:
- Cross-type loose equality. By default
==raises an error on a comparison that json-logic-js resolves tofalse, such as an object compared to a number. For the json-logic-js behavior, setloose_equality_errorstofalse. - Division by zero. A fractional dividend over zero returns
±f64::MAXby default (ReturnSaturated;ReturnNull,ThrowErrorandReturnInfinityare the otherdivision_by_zeromodes). An integer divided by an integer zero raisesThrown {"type": "NaN"}in every mode. - Empty objects.
{}is falsy, like[]; json-logic-js treats it as truthy. In Rust, aTruthyEvaluator::Customclosure can restore the json-logic-js rule. - Large integers. Integers keep their
i64value, so two integers above 2^53 that JavaScript rounds to one number compare as different. This applies to rules and data passed as JSON text; a JS number has already been rounded before the Node binding sees it.
These settings live on EvaluationConfig, or the config engine option in
the bindings; see Configuration.
Extensions you gain
Beyond the JSONLogic baseline, datalogic-rs adds operators the reference
engine does not ship: datetime arithmetic, string helpers (length,
starts_with, split, …), sort / slice / group_by / distinct,
keys / values / entries, try / throw, switch, and
flagd-compatible feature-flag operators (fractional, sem_ver). In the
Rust crate these sit behind Cargo features; every language binding enables
them all. See the operator overview.
You also gain checks json-logic-js does not have: engine.check(rule)
lists unknown operators (with a suggestion) and wrong argument counts
before the rule runs (Rule Analysis), and the
missing_var: "error" config setting makes a read of a missing data path
fail instead of returning null.
See Also
- Node.js Native (
@goplasmatic/datalogic-node) — drop-inapply(rule, data)and compiledRuleAPI for Node.js - Browser / Edge WASM (
@goplasmatic/datalogic-wasm) — WebAssembly package for browsers and edge workers - How It Compares — side-by-side comparison with
json-logic-jsand other engines - Operators Overview — built-in operators and extensions
- Configuration — customize equality, truthiness, and division-by-zero behavior
FAQ
General
What is JSONLogic?
JSONLogic is a way to write portable, safe logic rules as JSON. The specification is available at jsonlogic.com.
Why use datalogic-rs instead of the reference implementation?
- Performance: about 84x faster than json-logic-js across the shared benchmark suites (see Performance)
- One engine in eight runtimes: Rust, Node.js, browser WASM, Python, Go, JVM, .NET and PHP all run the same core
- Thread safety:
LogicisSend + Sync; wrap it inArcto share it - Extended operators: datetime, string, array and object helpers,
try/throw, flagd feature-flag operators - Rule checking:
checkreports unknown operators and wrong argument counts before a rule runs - No
unsafe: the crate is built with#![forbid(unsafe_code)]
Is datalogic-rs fully compatible with JSONLogic?
It passes the official JSONLogic test suite and adds operators beyond
the specification. A few defaults differ from json-logic-js (an empty
object is falsy, cross-type == raises an error); see
Coming from json-logic-js.
Rust Usage
Should I use v4 or v5?
Use v5 (5.9.0 is current) for new projects. The default build does
not pull in serde_json, and you can evaluate into your own arena. v5
has no compatibility shim for v4 code, so plan a single cutover; the
Migration Guide and the repo-root MIGRATION.md have the
details.
How do I share compiled rules across threads?
Logic is Send + Sync. Wrap it in Arc to share:
use datalogic_rs::Engine;
use std::sync::Arc;
let engine = Arc::new(Engine::new());
let compiled = engine.compile_arc(rule).unwrap();
let compiled_clone = Arc::clone(&compiled);
std::thread::spawn(move || {
let mut session = engine.session();
session.eval_str(&compiled_clone, data)
});
A Session borrows its engine. To store a session in a struct, send it
to another thread, or hold it across an .await, use SharedSession,
which holds the engine by Arc.
How do I find out which data fields a rule reads?
Ask the compiled rule for its facts (facts() in every binding):
use datalogic_rs::Engine;
let engine = Engine::new();
let logic = engine
.compile(r#"{"and": [{">": [{"var": "age"}, 18]}, {"==": [{"var": "country"}, "US"]}]}"#)
.unwrap();
let facts = logic.facts();
let reads: Vec<String> = facts.reads().iter().map(|p| p.segments().join(".")).collect();
assert_eq!(reads, ["age", "country"]);
assert!(facts.reads_complete()); // no computed path, no custom operator
The facts also list the operators the rule uses and whether its result depends only on its data. See Rule Analysis.
Why are custom operator arguments pre-evaluated in v5?
With pre-evaluated arguments, a custom operator works like a built-in:
the engine evaluates the arguments, hands you &DataValue<'a> borrows,
and you return another arena allocation. This drops the boundary
conversion the v4 Operator trait paid on every call and the separate
Evaluator trait.
A custom operator therefore cannot short-circuit. Lazy evaluation belongs
to built-ins such as and, or, if and ??.
What’s the difference between eval, eval_str, eval_into, and evaluate?
| Method | Input | Output | Notes |
|---|---|---|---|
datalogic_rs::eval_str (and eval / eval_into) | R: IntoLogic, D: OwnedInput | String (or OwnedDataValue / T) | Module-level helper backed by a default engine. Use it when you need no custom operators or configuration. |
Engine::eval_str (and eval / eval_into / eval_as) | R: IntoLogic, D: OwnedInput | String (or OwnedDataValue / T) | One-shot through a configured engine, with a fresh arena per call. |
Engine::evaluate | &Logic, any EvalInput, &Bump | &'a DataValue<'a> | Hot path. You own the arena; the result borrows from it. |
Session::eval_str (and eval / eval_into / eval_as) | &Logic, D: EvalInput | String (or OwnedDataValue / T) | Reuses the session’s arena across calls. You call session.reset() between batches. |
Session::eval_borrowed | &Logic, D: EvalInput | &'a DataValue<'a> | Zero-copy result; valid until the next &mut self call. |
The typed eval_into::<T> paths (and the serde_json::Value boundary
on EvalInput / IntoLogic) require feature = "serde_json".
JavaScript / WASM Usage
Do I need to call init() in Node.js?
No. Under Node.js the package resolves to its nodejs build, which
instantiates the module on load:
const { Engine } = require('@goplasmatic/datalogic-wasm');
new Engine().evalStr('{"==": [1, 1]}', '{}'); // "true"
On a Node.js server, the native @goplasmatic/datalogic-node package is
faster and takes JS objects.
Why do I need to JSON.stringify my data?
The WASM binding takes and returns JSON text, so rules, data and results cross the JS/WASM boundary as strings:
const engine = new Engine();
const rule = engine.compile(JSON.stringify(logic));
const value = JSON.parse(rule.evaluate(JSON.stringify(data)));
How do I use this with TypeScript?
The package includes type definitions:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
await init();
const engine = new Engine();
const result: string = engine.evalStr('{"==": [1, 1]}', '{}');
The free evaluate function and the CompiledRule class are deprecated
in 5.8.0 and removed in 6.0; use Engine.
React UI
Why does the editor need explicit dimensions?
The editor draws with React Flow, which needs a container with a defined height to lay out nodes and the viewport.
<div style={{ height: '500px' }}>
<DataLogicEditor value={expression} />
</div>
Can I use this with Next.js?
Yes. With the App Router, render it from a client component:
'use client';
import '@goplasmatic/datalogic-ui/styles.css';
import { DataLogicEditor } from '@goplasmatic/datalogic-ui';
export function Editor({ expression }) {
return <DataLogicEditor value={expression} />;
}
Operators
How do I access array elements by index?
Use the var operator with numeric path segments:
{"var": "items.0.name"}
What’s the difference between == and ===?
==: Loose equality (with type coercion, like JavaScript)===: Strict equality (no type coercion)
{"==": [1, "1"]} // true
{"===": [1, "1"]} // false
How do I handle missing data?
Test for it with missing or missing_some:
{"if": [
{"missing": ["user.email"]},
"Email required",
"Valid"
]}
Or give var a default:
{"var": ["user.email", "no-email@example.com"]}
or also works as a fallback, because it returns the deciding operand
rather than a boolean:
{"or": [{"var": "nickname"}, "Anonymous"]}
// Data: {"nickname": "Ada"} -> "Ada"
// Data: {} -> "Anonymous"
What happened to the preserve operator?
v5 removed it. Literal scalars and arrays pass through inline, and
templated objects belong in templating mode: Engine::compile_template
for one rule, or Engine::builder().with_templating(true).build() for the
engine (both need feature = "templating").
Configuration
How do I handle NaN in arithmetic?
Set NanHandling on the configuration:
use datalogic_rs::{Engine, EvaluationConfig, NanHandling};
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue);
let engine = Engine::builder().with_config(config).build();
Options: ThrowError (default), CoerceToZero (substitutes 0), IgnoreValue (skips the value), ReturnNull.
How do I make a misspelled data path fail instead of returning null?
Set MissingVar::Error ("missing_var": "error" in a binding’s config).
A var / val read that finds nothing then raises VariableNotFound
naming the path:
use datalogic_rs::{Engine, EvaluationConfig, MissingVar};
let config = EvaluationConfig::default().with_missing_var(MissingVar::Error);
let engine = Engine::builder().with_config(config).build();
let err = engine
.eval_str(r#"{">": [{"var": "user.agee"}, 18]}"#, r#"{"user": {"age": 30}}"#)
.unwrap_err();
assert_eq!(err.tag(), "VariableNotFound");
A var with a default, a field that is present and null, missing,
missing_some and exists behave as before, and try catches the error.
See Missing Variables.
How do I change division by zero behavior?
use datalogic_rs::{EvaluationConfig, DivisionByZeroHandling};
let config = EvaluationConfig::default()
.with_division_by_zero(DivisionByZeroHandling::ReturnNull);
Options: ReturnSaturated (default, f64::MAX/MIN with the dividend’s
sign), ThrowError, ReturnNull, ReturnInfinity.
The setting applies to the float path only: an integer dividend over an
integer zero ({"/": [10, 0]}) raises Thrown { type: "NaN" } under
every setting. {"/": [10.5, 0]} takes the configured path. See
Division by Zero for the
comparison table.
Troubleshooting
“Invalid operator” error
Outside templating mode, an unknown operator key compiles and raises
InvalidOperator when evaluation reaches it. check finds it before
the rule runs and suggests the operator one edit away (see
Rule Analysis). Then either:
- Fix the operator name (operators are case-sensitive)
- Register a custom operator on the builder
- Compile the rule as a template (
Engine::compile_template,feature = "templating") if the key is meant as an output field
My template key runs as an operator instead of being emitted
This is the inverse problem, and it raises no error, only a wrong result.
In templating mode a single-key object whose key names an operator is an
operator call, so {"type": {"var": "x"}} runs the type operator rather
than emitting a type field. Every built-in name is affected (87 names,
counting the aliases var, ?: and match, in a build with every
operator family), plus any custom operator you registered.
Turn on the key escape and prefix the key:
Engine::builder().with_templating(true).with_template_key_escape('$'),
then write {"$type": ...} to emit type and {"$$type": ...} to emit
a literal $type. It is off by default, so existing templates keep their
meaning. check in template mode also warns about an output key one edit
away from an operator name. Details in
Structured Objects
and Troubleshooting.
Performance issues with large expressions
- Use a
Sessionfor repeated calls, so they reuse one arena - For the hottest path, call
Engine::evaluatewith abumpalo::Bumpyou manage - Use
feature = "trace"to see which sub-expressions run and how often (the trace records iteration counts, not timings), and a sampling profiler such as perf or Instruments for timing (see Performance)
WASM initialization fails
With the web build, await init() before you construct an Engine:
await init();
const engine = new Engine();
For more troubleshooting, see the Troubleshooting Guide.
See Also
- Troubleshooting — solutions for common Rust, WASM, and React UI errors
- Basic Concepts — how JSONLogic rules, truthiness, and data contexts work
- Migration Guide — upgrading across 5.x releases or from v4
- Coming from json-logic-js — differences from the reference JS implementation
Troubleshooting
Common issues and solutions for datalogic-rs.
Rust Issues
“Invalid operator: xyz”
Cause: The rule calls an operator name the engine does not know.
compile accepts the call, and the error appears when evaluation reaches
it. The name may be misspelled, belong to an operator family this build
or engine leaves out (a Cargo feature, or EngineBuilder::with_families),
or be meant as an output key.
Solutions:
- Check the operator name spelling (operators are case-sensitive).
- Register a custom operator on the builder.
- Compile in templating mode (requires
feature = "templating"); unknown keys then become literal output fields.
// Option 1: Fix spelling
let logic = r#"{"and": [...]}"#; // not "AND"
// Option 2: Custom operator
let engine = datalogic_rs::Engine::builder()
.add_operator("xyz", XyzOperator)
.build();
// Option 3: Templating mode (feature = "templating"), for the engine
// or for one compile
#[cfg(feature = "templating")]
let engine = datalogic_rs::Engine::builder().with_templating(true).build();
#[cfg(feature = "templating")]
let template = datalogic_rs::Engine::new().compile_template(logic)?;
To catch the error before any input reaches it, compile with
Engine::compile_checked or run Engine::check, which also suggests a
known name one edit away (see
Finding every problem in a rule).
A template key runs as an operator instead of being emitted
Cause: This is the inverse of the error above, and it is quieter: you get no error, only the wrong result. In templating mode a single-key object whose key names an operator is an operator call, so a key that happens to name a built-in runs the operator instead of becoming an output field.
{ "type": { "var": "x" } }
// against {"x": 1} -> "number" (the `type` operator ran)
// expected -> {"type": 1}
Every built-in operator name is affected (87 names, aliases included,
with every operator feature on): type, map, filter, if, keys,
values, entries, length, in, sort, now, try, cat, +,
== and the rest of the operator table, plus any custom operator you
registered. The same key behaves differently with siblings:
{"type": X, "other": 1} emits both keys, because multi-key object keys
are always literal.
Solution: enable the key escape and prefix the key with it. The engine strips exactly one leading prefix and never resolves an escaped key as an operator.
#[cfg(feature = "templating")]
let engine = datalogic_rs::Engine::builder()
.with_templating(true)
.with_template_key_escape('$')
.build();
{ "$type": { "var": "x" } } // -> {"type": 1}
{ "$$type": 1 } // -> {"$type": 1} (doubling escapes the sigil)
The prefix is a char, not a fixed $, so payloads that already use $
keys (MongoDB documents, JSON Schema output) can pick ~ or # instead.
The setting is off by default, requires feature = "templating", and
applies only to rules compiled in templating mode (on a templating engine
or through compile_template). See
Structured Objects
for the full rules.
A misspelled path returns null instead of failing
Cause: JSONLogic reads a path that finds nothing as null, so
{"var": "usr.name"} against {"user": {"name": "ada"}} is null, and
the null flows on into comparisons and arithmetic.
Solution: if your data has a known shape, make a miss an error:
use datalogic_rs::{Engine, EvaluationConfig, MissingVar};
let engine = Engine::builder()
.with_config(EvaluationConfig::default().with_missing_var(MissingVar::Error))
.build();
let err = engine.eval_str(r#"{"var": "usr.name"}"#, r#"{"user": {"name": "ada"}}"#).unwrap_err();
assert_eq!(err.to_string(), "Variable not found: usr.name (in operator: var)");
The bindings take the same setting as the config key
"missing_var": "error". Iterators run their general path under this
setting, so map, filter and reduce over fields are slower (see
Performance).
“Variable not found”
Cause: The engine is configured with MissingVar::Error, and a var
or val read found nothing at its path. Under the default
MissingVar::Null the read is null and raises nothing.
Solutions:
- Check the variable path spelling.
- Give the read a default: a read with a default is not a miss.
- Test with
missingorexistsfirst (they never raise this error), or wrap the read intry.
{"var": ["user.name", "Anonymous"]}
{"if": [
{"missing": ["user.name"]},
"No name",
{"var": "user.name"}
]}
{"try": [{"var": "user.name"}, "No name"]}
Unexpected NaN / Thrown errors from arithmetic
Cause: A value in an arithmetic operation does not coerce to a number.
The default NanHandling::ThrowError raises a Thrown error carrying
{"type": "NaN"}. The strings "NaN", "inf" and "infinity", and
numeric literals that overflow f64, do not coerce either.
Solution: Configure NaN handling:
use datalogic_rs::{Engine, EvaluationConfig, NanHandling};
let config = EvaluationConfig::default()
.with_arithmetic_nan_handling(NanHandling::IgnoreValue); // or CoerceToZero, ReturnNull
let engine = Engine::builder().with_config(config).build();
IgnoreValue skips the value ({"*": [2, "x", 3]} is 6), CoerceToZero
substitutes 0 (0), and ReturnNull makes the whole operation null.
Matching on the kind of an error
Switch on Error::code(), which returns an ErrorCode (Copy, Eq,
Hash, present in every build), instead of comparing Error::tag()
strings or matching ErrorKind with its payload:
use datalogic_rs::{Engine, ErrorCode};
let engine = Engine::new();
match engine.eval_str(r#"{"throw": "rejected"}"#, "null") {
Ok(out) => println!("{out}"),
Err(e) => match e.code() {
ErrorCode::Thrown => println!("rule threw {:?}", e.thrown_value()),
ErrorCode::BudgetExceeded => println!("too expensive"),
_ => println!("failed: {e}"),
},
}
ErrorCode::as_str() gives the name tag() returns, which is also the
name the bindings put on the wire. That name parses back with
str::parse, and ErrorCode::ALL lists every code. ErrorCode is
#[non_exhaustive], so keep a wildcard arm.
Error::operator() names a nested operator
Error::operator() (and the serialized error’s "operator" field) names
the innermost operator that failed, custom operators included, and plain
and traced runs agree. {"if": [true, {"+": [1, {"abs": ["x"]}]}, 0]}
reports abs. Releases before 5.8.0 reported the rule’s root operator
unless a deeper site set one, so code that expected the root (if here)
needs updating. Error::node_ids() still carries the whole breadcrumb
from the failing node to the root, and Error::resolve_path(&logic) turns
it into named steps.
Finding every problem in a rule
compile stops at the first problem and leaves calls that cannot succeed
to fail at evaluation. Engine::check reports every problem it can see,
in every branch, each with an RFC 6901 JSON Pointer into the rule:
use datalogic_rs::{CheckMode, Engine};
let engine = Engine::new();
let rule = r#"{"if": [true, {"vr": "x"}, {"map": [1]}]}"#;
for d in engine.check(rule, CheckMode::Engine) {
println!("{d}");
}
// error at /if/1: unknown operator `vr`; did you mean `var`?
// error at /if/2: `map` takes exactly 2 arguments, not 1
Engine::compile_checked compiles only a rule with no error diagnostic
and otherwise returns them all in a CompileError. See
Rule Analysis.
A rule behaves differently on another engine
Any engine can evaluate a Logic compiled on another. Constant folding
runs under the compiling engine’s number coercion, NaN and division
handling, loose equality and truthiness, so the engine that evaluates the
rule recompiles it (once per distinct setting, cached on the rule) when
those settings differ, and the result follows the evaluating engine. Other
differences remain:
- Custom operators: the rule finds the evaluating engine’s operator by name, which may be a different implementation.
- Operator families: the rule keeps the operators it was compiled with, whatever families the evaluating engine has.
- Speed: the rule is evaluated without input projection.
Logic::compiled_on(&engine) tells you whether that engine compiled the
rule. In 6.0 a rule evaluates only on its own engine.
An object with a datetime key is not treated as a datetime
Only a single-key object, {"datetime": "..."} or {"timestamp": "..."},
is the boundary form of a datetime or duration. A record that has such a
field next to others ({"datetime": "2024-01-01T00:00:00Z", "id": 1}) is
ordinary data: ==, ===, in and distinct compare all of its
fields, and type reports "object". Before 5.8.0 any object carrying
the key counted as a datetime. To compare the timestamps, read the field:
{"==": [{"var": "a.datetime"}, {"var": "b.datetime"}]}.
“the trait bound T: CustomOperator is not satisfied” / Send-Sync errors
Cause: The custom operator type is not Send + Sync.
Solution: Use thread-safe primitives. Avoid Rc, RefCell, etc., in
operator state; wrap shared state in Arc<Mutex<_>> or atomics.
v4 method calls fail to compile in v5
Cause: v5 renamed the public surface (DataLogic → Engine,
CompiledLogic → Logic, Operator → CustomOperator,
evaluate_* → eval_*, etc.) and removed the pre-release compat
shim. v5 is a hard cliff: there is no transitional feature flag.
Solutions:
- Follow the conceptual overview in the Migration Guide
and the per-call cookbook in the repo-root
MIGRATION.md. - Common mappings:
DataLogic::with_config(c)→Engine::builder().with_config(c).build()engine.evaluate_json(rule, data)→engine.eval_str(rule, data)(ordatalogic_rs::eval_str(rule, data)for the zero-config path)engine.evaluate_owned(&compiled, data)→let v: serde_json::Value = engine.session().eval_into(&compiled, &data)?(requiresfeature = "serde_json";Engine::eval_intotakes a rule source, not a compiled&Logic)engine.evaluate_json_with_trace(rule, data)→engine.trace().eval_str(rule, data)returningTracedRun<String>
Slow compilation
Cause: Large or deeply nested expressions.
Solutions:
- Compile once, evaluate many times.
- Break expressions into smaller composable pieces.
- Use
feature = "trace"to see which sub-expressions run and how often (the step log carries iteration counts, not timings); for timing, use a sampling profiler such as perf or Instruments (see Performance)
let compiled = engine.compile(rule).unwrap();
let mut session = engine.session();
for data in dataset {
session.eval_str(&compiled, data)?;
session.reset();
}
JavaScript / WASM Issues
“TypeError: Cannot read properties of undefined”
Cause: On the web target, the code called into the module before
init() finished loading it. The message names an internal export, for
example (reading '__wbindgen_add_to_stack_pointer').
Solution: Await init() once before creating an Engine:
import init, { Engine } from '@goplasmatic/datalogic-wasm';
await init();
const engine = new Engine();
engine.evalStr(logic, data);
“TypeError: init is not a function”
Cause: Under Node the package resolves to its nodejs target, which
loads the module on import and has no init loader; a default import
binds the module namespace instead.
Solutions:
// Code shared between browser and Node: guard the call
import init, { Engine } from '@goplasmatic/datalogic-wasm';
if (typeof init === 'function') await init();
// Node.js only: no init needed
const { Engine } = require('@goplasmatic/datalogic-wasm');
On a Node server, the native @goplasmatic/datalogic-node package is
faster than WASM; see Performance.
“Failed to fetch” in browser
Cause: The browser cannot load the .wasm file. On the web target,
init() fetches datalogic_wasm_bg.wasm from next to
datalogic_wasm.js (resolved against import.meta.url) unless you pass
one: init({ module_or_path: url }).
Solutions:
- Check that your bundler copies the
.wasmfile into the build output. - Check that your server serves it at that URL, with
Content-Type: application/wasm(without it the loader falls back to a slower path and logs a warning). - Check CORS headers if loading from a CDN.
For Webpack:
// webpack.config.js
module.exports = {
experiments: {
asyncWebAssembly: true,
},
};
Results are strings, not values
Cause: The WASM binding takes and returns JSON strings, not native values.
Solution: Parse the result:
const resultString = engine.evalStr(logic, data);
const result = JSON.parse(resultString);
Performance issues
Cause: Recompiling rules repeatedly.
Solution: Compile once with engine.compile and evaluate the Rule:
const engine = new Engine();
const rule = engine.compile(logic);
for (const item of items) {
rule.evaluate(JSON.stringify(item));
}
The CompiledRule class and the free evaluate / evaluateWithTrace
functions are deprecated and removed in 6.0. Build an Engine and use
compile, evalStr and engine.evaluateWithTrace.
React UI Issues
“ResizeObserver loop completed with undelivered notifications”
Cause: The container size changes rapidly. The browser reports the skipped notifications; the warning is usually harmless.
Editor shows blank / empty
Causes:
- Container has no dimensions
- CSS not imported
- Expression is null
Solutions:
<div style={{ width: '100%', height: '500px' }}>
<DataLogicEditor value={expression} />
</div>
import '@goplasmatic/datalogic-ui/styles.css';
Debugger controls not showing
Cause: data prop not provided.
Solution:
<DataLogicEditor
value={expression}
data={{ x: 1, y: 2 }}
/>
With data the toolbar gains the debugger controls (play/pause, step, and a
step timeline). Values appear as you step: the current node shows its context
and result in a bubble. Nodes show no results until you step.
SSR / Hydration errors in Next.js
Cause: The editor loads the WASM engine in the browser; server rendering cannot run it.
Solution: Use a client component with dynamic import:
'use client';
import dynamic from 'next/dynamic';
const DataLogicEditor = dynamic(
() => import('@goplasmatic/datalogic-ui').then(mod => mod.DataLogicEditor),
{ ssr: false }
);
Build Issues
WASM build fails
Cause: Missing wasm-pack or target.
Solution:
cargo install wasm-pack
rustup target add wasm32-unknown-unknown
cd bindings/wasm && ./build.sh
TypeScript errors with imports
{
"compilerOptions": {
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
}
}
Bundler can’t find WASM file
// Webpack: enable async WASM
experiments: { asyncWebAssembly: true }
Getting Help
If you can’t resolve an issue:
- Check existing issues
- Create a minimal reproduction
- Open a new issue with:
- datalogic-rs version
- Environment (Rust / Node / Browser)
- Minimal code to reproduce
- Expected vs actual behavior
See Also
- FAQ — answers to common questions about JSONLogic and datalogic-rs
- Rule Analysis — catch rule errors before evaluation with
Engine::checkandcompile_checked - Configuration — configure
MissingVar,NanHandling, andDivisionByZeroHandling - Migration Guide — upgrading from v4 or earlier 5.x versions