Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. 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
  2. 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 stackStart here
RustRust (native crate)
Node.js servicesNode.js (native) · Integration: Express
Browser, edge, Deno, BunJavaScript (WASM) · Browser & Frameworks
PythonPython · Quick Start
GoGo · Quick Start
Java, Kotlin, ScalaJava / Kotlin (JVM) · Integration: Spring Boot
.NET (C#, F#).NET
PHPPHP
Any other languageC ABI
React rule-builder UIReact Visual Debugger

How these docs are organized

Next steps

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

  1. Logic: Enter your JSONLogic expression in the Logic panel
  2. Data: Enter the JSON data to evaluate against in the Data panel
  3. Result: Read the evaluated output, or the engine’s error with its type and failing operator
  4. 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)
  5. Examples: Use the Load Example… dropdown to load pre-built examples
  6. Templating: Tick the checkbox to compile multi-key objects as output templates with embedded JSONLogic

Quick Reference

Basic Operators

OperatorExampleDescription
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

OperatorExampleDescription
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

OperatorExampleDescription
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

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:

FeatureDefaultWhat it adds
serde_jsonoff&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.
templatingoffTemplating mode: engine-wide with Engine::builder().with_templating(true).build(), or for one rule with Engine::compile_template.
datetimeoffdatetime, 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).
traceoffPer-evaluation execution tracing (engine.trace()…). Transitively enables serde_json.
ext-stringoffExtended string operators (length, starts_with, ends_with, upper, lower, trim, split).
ext-arrayoffExtended array operators (sort, slice, group_by, distinct).
ext-objectoffObject take-apart operators (keys, values, entries).
ext-controloffExtended control-flow operators (exists, ??, switch/match, type).
error-handlingofftry / throw operators.
ext-mathoffExtended math operators (abs, ceil, floor).
flagdoffOpenFeature flagd-compatible fractional (murmurhash3 percentage bucketing) and sem_ver (semantic-version comparison) operators.
wasm-clockoffJS-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).
tensoroffThe 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-halfoffLifts the f16 / bf16 restriction on the element-wise tensor operators. Implies tensor; pulls in half.
budgetoffA 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-operatorsoffEvery 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: DataLogic engine with a serde_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:

LanguagePackageInstallDeep-dive
Node.js (native, napi-rs)@goplasmatic/datalogic-nodenpm i @goplasmatic/datalogic-nodeNode.js docs
JavaScript / TypeScript (WASM)@goplasmatic/datalogic-wasmnpm i @goplasmatic/datalogic-wasmJS / TS docs
Pythondatalogic-pypip install datalogic-pyPython docs
Godatalogic-gogo get github.com/GoPlasmatic/datalogic-rs/bindings/go/v5Go docs
JVM (Java, Kotlin, Scala)io.github.goplasmatic:datalogicio.github.goplasmatic:datalogic:5.9.0 (Maven Central, JDK 22+)Java / Kotlin docs
.NETGoplasmatic.Datalogicdotnet add package Goplasmatic.Datalogic.NET docs
PHPgoplasmatic/datalogiccomposer require goplasmatic/datalogicPHP docs
C ABI (any FFI host)datalogic-c (in-tree, not published)build bindings/c from sourceC ABI docs
React (visual debugger)@goplasmatic/datalogic-uinpm i @goplasmatic/datalogic-uiReact 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

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

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 element
  • val with 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 a Thrown { type: "NaN" } error by default; change that with EvaluationConfig::arithmetic_nan_handling
  • Integers stay exact up to the i64 range: 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

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

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

CategoryOperatorsDescription
Variable Accessval (alias var), existsAccess and check data
Comparison==, ===, !=, !==, >, >=, <, <=Compare values
Logical!, !!, and, orBoolean logic
Arithmetic+, -, *, /, %, max, min, abs, ceil, floorMath operations
Control Flowif (alias ?:), ??, switch (alias match), typeConditional branching
Stringcat, substr, in, length, starts_with, ends_with, upper, lower, trim, splitString manipulation
Arraymerge, filter, map, reduce, all, some, none, sort, slice, group_by, distinctArray operations
Objectkeys, values, entriesObject take-apart
DateTimedatetime, timestamp, parse_date, format_date, date_diff, nowDate and time
Missing Valuesmissing, missing_someCheck for missing data
Error Handlingtry, throwException handling
Tensortensor, zeros, full, scatter, rle_expand, one_hot, stack, concat, unstack, reshape, transpose, pad, crop, cast, normalize, argmax, gather, to_list, shape, dtypeMarshalling JSON to and from typed n-dimensional buffers; requires features = ["tensor"]
flagd-Compatfractional, sem_verFeature-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 featureOperators
baseline (always on)val/var, ==, ===, !=, !==, >, >=, <, <=, !, !!, and, or, if/?:, +, -, *, /, %, max, min, cat, substr, in, merge, filter, map, reduce, all, some, none, missing, missing_some
datetimedatetime, timestamp, parse_date, format_date, date_diff, now
ext-stringlength, starts_with, ends_with, upper, lower, trim, split
ext-arraysort, slice, group_by, distinct
ext-objectkeys, values, entries
ext-controlexists, ??, switch/match, type
error-handlingtry, throw
ext-mathabs, ceil, floor
tensortensor, zeros, full, scatter, rle_expand, one_hot, stack, concat, unstack, reshape, transpose, pad, crop, cast, normalize, argmax, gather, to_list, shape, dtype
flagdfractional, 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 value
  • or: stops at the first truthy value
  • if (and its alias ?:): evaluates only the matching branch
  • ??: stops at the first value that is not null
  • switch (and its alias match): evaluates only the matching case’s result, or the default
  • try: 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 the type operator rather than emitting a type field, 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 emit type ({"$$type": ...} emits a literal $type). See Structured Objects.

Variable Access Operators

These operators access data from the evaluation context.

Feature flags (Rust crate). var and val are baseline; exists requires the ext-control feature. Every language binding enables all operator features. An engine built with EngineBuilder::with_families has exists only if it names Family::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" } is null. Use substr to 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 null stays null
  • Returns null if the path doesn’t exist and no default is provided, or raises an error under MissingVar::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 into a then b
  • 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 walks a then b. Use { "var": ["a", "default"] } or { "??": [{ "val": "a" }, "default"] } for a fallback. Under MissingVar::Error the ?? form raises; see Missing paths
  • A string computed at runtime is read the way var reads a computed path: { "val": { "cat": ["a", ".b"] } } reads the key "a.b" if present, otherwise a then b. In the array form, a computed segment containing dots is walked as a dotted path
  • Logic::to_json() writes a dotted val key 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 frame ceil(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.
  • index and key are null wherever no such metadata exists: key over an array, anything inside reduce or a try catch 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 called index on 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 to null.
  • var accepts the same [[N], ...] form, because it compiles to val.

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:

casejson-logic-enginedatalogic
odd level with a non-metadata paththe {iterator, index} object, or nullthe same frame as N + 1
a level past the outermost framenullthe root data
{ "val": [[0], "index"] }the element’s index fieldthe 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 var operation 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 null or "" exists
  • Segments must be object keys: exists does not index arrays, so { "exists": ["items", 0] } is false even though { "val": ["items", 0] } resolves. Test array elements with { "!==": [{ "val": ["items", 0] }, null] } or length instead
  • An empty segment list, { "exists": [] }, returns true

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 var with a default: { "var": ["x", 0] } on {} is 0
  • a path that is present and holds null
  • { "var": "" } and { "val": [] }, the current data
  • iteration metadata ({ "val": [[1], "index"] }), which is null outside an iterator
  • missing, missing_some and exists, 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

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 compare
  • c, … - Optional further values; each must equal a

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:

OperandsRule
number and stringThe string is read as a number: { "==": [1, "1"] } is true. An integer string matches an integer exactly, also above 2^53
number and booleantrue is 1, false is 0
string and booleanThe string must be "true" or "false": { "==": ["false", false] } is true, { "==": ["", false] } is false
null and number, boolean or stringnull equals 0, false and ""
two arraysEqual when their elements are equal under ===: { "==": [[1], [1.0]] } is true, { "==": [[1], ["1"]] } is an error (see below)
two objectsfalse; 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 compare
  • c, … - Optional further values; each must equal a

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 returns false

!= (Not Equals)

Loose inequality comparison with type coercion.

Syntax:

{ "!=": [a, b] }

Arguments:

  • a - First value
  • b - 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] } is false
  • Fewer than two operands is an Invalid Arguments error

!== (Strict Not Equals)

Strict inequality comparison without type coercion.

Syntax:

{ "!==": [a, b] }

Arguments:

  • a - First value
  • b - 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 compare
  • c, … - 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 compare
  • c, … - 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 compare
  • c, … - 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 compare
  • c, … - 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 to a < x AND x < b, which suits range checks
  • The operators accept any number of operands: { "<": [1, 2, 3, 4] } is true and { "<": [1, 2, 5, 4] } is false. Evaluation short-circuits at the first failing pair and never evaluates later operands ({ ">": [5, 3, 4, { "throw": "boom" }] } is false, nothing is thrown)
  • Fewer than two operands ({ "<": [1] }, { "<": [] }) is an Invalid Arguments error
  • Two strings compare lexicographically ({ ">": ["10", "9"] } is false); a string against a number is coerced numerically ({ ">": ["10", 9] } is true). A string that is not a finite number against a number throws a NaN error ({ "<": ["abc", 1] }, { "<": [1, "inf"] })
  • null and booleans compare as numbers ({ "<": [null, 1] } and { "<": [false, true] } are true)
  • An array or object operand (other than a datetime or duration value) throws a NaN error

See Also

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 ({ "!!": [[]] } is false, { "!!": [{}] } is false)

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 returns null; 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 returns null; 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 or can supply a default value

Truthiness Reference

The default JavaScript-style truthiness:

ValueTruthy?
trueYes
falseNo
1, 2, -1, 3.14Yes
0, 0.0No
"hello", "0", "false"Yes
""No
[1, 2], {"a": 1}Yes
[]No
{}No
nullNo

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

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, and max are baseline; abs, ceil, and floor require the ext-math feature. Every language binding enables all operator features. An engine built with EngineBuilder::with_families has abs, ceil and floor only if it names Family::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 (from var, for example), + sums the elements; a literal array written in the rule is a non-numeric operand (a NaN error 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 a NaN error under the default EvaluationConfig::arithmetic_nan_handling. Use cat to join strings
  • { "+": [] } returns 0
  • An integer sum that overflows i64 continues as a float: { "+": [9223372036854775807, 1] } is 9.223372036854776e18

- (Subtract)

Subtract numbers.

Syntax:

{ "-": [a, b] }
{ "-": [a, b, c, ...] }
{ "-": value }

Arguments:

  • a - Value to subtract from
  • b, 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]] } is 7)

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 (a NaN error 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 - Dividend
  • b, 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]] } is 10)

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] }, since 0.0 is integral-valued), the engine throws a NaN error under every configuration. A single zero operand ({ "/": 0 }) is a NaN error too
  • Any other zero divisor follows EvaluationConfig::division_by_zero: a non-integral dividend ({ "/": [10.5, 0] }) or a divisor coerced from null, a boolean, or a string ({ "/": [10, null] }, { "/": [10, "0"] }). The default, DivisionByZeroHandling::ReturnSaturated, returns f64::MAX (f64::MIN for a negative dividend) instead of Infinity
  • The other modes are ReturnInfinity, ReturnNull and ThrowError (see Division by Zero). Choose ThrowError if a try fallback should cover every zero-like divisor

% (Modulo)

Calculate remainder of division.

Syntax:

{ "%": [a, b] }
{ "%": [a, b, c, ...] }

Arguments:

  • a - Dividend
  • b, 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 a NaN error; otherwise ({ "%": [10.5, 0] }, { "%": [10, null] }) the result follows EvaluationConfig::division_by_zero, default ReturnSaturated

max

Find the maximum value.

Syntax:

{ "max": [a, b, ...] }
{ "max": array }

Arguments:

  • a, b, … - Values to compare, or
  • array - A single value (such as a var) 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. max does not coerce strings (even numeric ones such as "1"), booleans, or null; they throw Invalid Arguments. This differs from json-logic-js, which coerces { "max": ["1", 5, "3"] } to 5
  • 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, or
  • array - A single value (such as a var) 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. min does not coerce strings (even numeric ones), booleans, or null; 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_handling does 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. Use map to apply these to a data-driven array
  • ceil and floor return an integer when the result fits in i64, and a float otherwise ({ "ceil": 1e20 } is 1e20)

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 errorNaN error
IgnoreValue5 (skips "x")6 (skips "x")
CoerceToZero5 ("x" is 0)0 ("x" is 0)
ReturnNullnullnull

/ 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

Control Flow Operators

Conditional branching and value selection operators.

Feature flags (Rust crate). if and ?: are baseline; ??, switch/match, and type require the ext-control feature. 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 evaluate
  • then_value - Value if condition is truthy
  • else_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
  • if takes 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 evaluate
  • then_value - Value if condition is truthy
  • else_value - Value if condition is falsy (optional; null when 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 the if section 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 or if 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 equals value selects its result
  • default - Optional value used when no case matches (omitted: returns null)

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 1 does not match the string "1".
  • switch evaluates the discriminant once and compares it against each case in order.
  • switch evaluates only the matching case’s result (or the default).
  • Case keys may be expressions: { "switch": [{ "var": "x" }, [[{ "var": "y" }, "dyn"]], "d"] } returns "dyn" when x equals y.
  • 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.
  • switch reads 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:

  • type reads exactly one argument. The engine parses a literal array such as { "type": [1, 2, 3] } as a multi-argument call, so type inspects 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 datetime feature, type classifies 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 of now, datetime and timestamp are 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

OperatorUse CaseFalsy Handling
ifComplex branching, multiple conditionsEvaluates truthiness
?:Simple if/elseEvaluates truthiness
??Default for null onlyOnly skips null
orDefault for any falsySkips 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

String Operators

String manipulation and searching operations.

Feature flags (Rust crate). cat, substr, and in are baseline. length, starts_with, ends_with, upper, lower, trim, and split require the ext-string feature. 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 string
  • start - 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 for
  • haystack - 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"] } is false; the engine never stringifies numbers for substring search
  • Array membership uses strict deep equality (the same predicate as distinct): { "in": ["2", [1, 2, 3]] } is false, while { "in": [1, [1.0, 2]] } is true and nested arrays/objects compare structurally
  • Any other haystack (an object, null, a missing variable) yields false, 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:

  • length takes 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]] } is 3.
  • length accepts only strings and arrays: null (including a missing var), numbers, booleans, and objects throw Invalid Arguments. Guard optional fields with ??, for example { "length": { "??": [{ "var": "items" }, []] } }, which is 0 when items is absent.
  • String length counts Unicode characters, not bytes ({ "length": "héllo" } is 5).

Try it:


starts_with

Check if a string starts with a prefix.

Syntax:

{ "starts_with": [string, prefix] }

Arguments:

  • string - String to check
  • prefix - 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 check
  • suffix - 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:

  • upper and lower apply Unicode’s full case mappings (Rust’s str::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 split
  • delimiter - 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:

  • split reads 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

Operations for working with arrays, including iteration and transformation.

Feature flags (Rust crate). All array operators are baseline except sort, slice, group_by, and distinct, which require the ext-array feature. Every language binding enables all operator features. An engine built with EngineBuilder::with_families has those four only if it names Family::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; merge wraps 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 merge calls for deeper structures
  • null arguments and null elements 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:

  • filter does not modify the original array
  • A field an element lacks reads as null, so { "filter": [{ "var": "rows" }, { "===": [{ "var": "v" }, null] }] } keeps the elements without v as well as those whose v is null
  • filter filters 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 null or 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 transform
  • transformation - 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 null or missing input yields []; map treats 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 reduce
  • reducer - Operation combining accumulator and current element
  • initial - Initial value for accumulator (optional). When omitted, the first element seeds the accumulator and reduction starts from the second; an empty array without initial yields null

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 returns initial unchanged (null when initial is 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 check
  • condition - 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: all is false, some is false, none is true

some

Check if any element satisfies a condition.

Syntax:

{ "some": [array, condition] }

Arguments:

  • array - Array to check
  • condition - 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 check
  • condition - 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, false sorts descending
  • key_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, false descending. 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. sort has no a/b comparator 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 is null.
  • A null or missing input yields null (a literal null argument is Invalid Arguments). This differs from group_by and distinct, which yield [] for null input. 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); null means the default (the first element)
  • end - Ending index, exclusive (optional, negative counts from end); null means the default (the end of the collection)
  • step - Optional stride (default 1); 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 null or missing collection yields null, without evaluating the bounds
  • start, end and step must be integers or null. A whole float (2.0) counts as an integer; a fractional bound (1.5, written or computed), a string ("1") or a boolean is a NaN error
  • step of 0 is 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, like sort’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_by keeps 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 a null key.
  • null or 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, distinct deduplicates 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 in uses): 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 a datetime field but differ elsewhere stay distinct.
  • null or empty input yields []. Non-array input is an error.

See Also

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-object feature. Every language binding enables all operator features. An engine built with EngineBuilder::with_families has them only if it names Family::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):

  • null input, 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 ParsedData and string entry points, and the Node, WASM, and C-based bindings) keeps source-text order and duplicate keys, while data passed as a serde_json::Value or 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

DateTime Operators

Operations for working with dates, times, and durations.

Feature flag (Rust crate). All datetime operators require the datetime feature (which pulls in chrono and chrono-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 ==, ===, in and distinct compare every field and type reports "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:MM offset; fractional seconds and a space instead of T are accepted), or a naive YYYY-MM-DDTHH:MM:SS string, which the engine reads as UTC. Date-only strings (2024-01-01) and colon-less offsets (-0500, +05) fail with Invalid datetime format; use parse_date for 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 - Days
  • h - Hours
  • m - Minutes
  • s - 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 with Invalid 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 parse
  • format - Format string using simplified tokens
  • timezone - 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:

TokenDescriptionExample
yyyy4-digit year2024
MMMMfull month nameJanuary
MMMabbreviated month nameJan
MM2-digit month01-12
dd2-digit day01-31
HH2-digit hour (24h)00-23
mm2-digit minute00-59
ss2-digit second00-59
EEEEfull weekday nameMonday
EEEabbreviated weekday nameMon

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 raises Invalid Arguments (naming the operator, not the zone) when the rule is evaluated, and Engine::check / Engine::compile_checked report the zone before the rule runs. A zone computed at evaluation time, from data or from another operator such as cat, fails with Unknown 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 format
  • format - Format string using simplified tokens (same as parse_date)
  • timezone - Optional IANA zone name (e.g. "Asia/Kolkata"). When present, format_date renders 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.
  • z is honoured only when it is the entire format string. Inside a longer format it is emitted literally: "yyyy-MM-dd HH:mm z" on 2024-01-01T10:00:00+05:30 gives "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 %z gives +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 %z or %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 datetime
  • date2 - Second datetime
  • unit - 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

Missing Value Operators

Operators for checking whether data fields are absent.

Feature flags (Rust crate). missing and missing_some are 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"] } checks a, b and c

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:

  • missing reports only absent paths. A key that exists with a null or 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 is null or ""
  • Dot-separated names walk nested objects ("user.email"), and numeric segments index arrays ("items.0")
  • Under MissingVar::Error (see Missing Variables), missing and missing_some still return the absent paths; they never raise VariableNotFound

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 holding null or "" counts as present
  • A fractional minimum rounds up: 2.5 needs 3 present fields. A minimum of 0 or less is always met. A minimum that is not a number ("2", null, true) counts as 1
  • 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

Scenariomissingmissing_some
All fields required{ "!": { "missing": [...] } }n/a
At least N requiredn/a{ "!": { "missing_some": [N, [...]] } }
ResultEvery absent fieldThe 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

Error Handling Operators

Operators for throwing and catching errors inside a rule.

Feature flag (Rust crate). try and throw require the error-handling feature. 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 error
  • fallback - Value or expression to use if an error occurs
  • try tries 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: var and val return null for an absent path, so try does not fall back on missing data. Use ?? or var’s default argument for that (see Control Flow). An engine configured with EvaluationConfig::missing_var set to MissingVar::Error raises VariableNotFound instead, and try catches it like any other error (see Missing Variables).
  • An exhausted operation budget (BudgetExceeded) is final: try passes 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 throw produces 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, under MissingVar::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’s type field, or
  • error_object - An error object value (sourced from data, or built in templating mode) with arbitrary keys such as code and message. 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: type is an operator name, so it runs the type operator on "X" and throws { "type": "string" }. An error object carrying a type key 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

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 tensor feature. tensor-half also enables f16 / bf16 elements. Every language binding enables tensor but not tensor-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 tensor
  • dtype: 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] writes value (1 by default); [i, j, v] writes v
  • value: 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 raises too many arguments (both InvalidArguments).
  • Size cap. zeros, full, scatter, rle_expand, one_hot and pad size a new buffer from dimensions in the rule. Past 2^28 (268,435,456) elements, 2 GiB of f64, they raise InvalidArguments("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"}]} throws x.

How a tensor behaves as a value

ContextBehavior
type"tensor"
Truthinesstrue 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 coercionNone. A one-element tensor does not coerce the way a one-element array does; to_list is the explicit way out
sortTensors rank after objects; two tensors order by dtype, then shape, then payload bytes
map, array elements, object fieldsPassed 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

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). fractional and sem_ver require the flagd feature (off by default in the Rust crate; enabled in every language binding). An engine built with EngineBuilder::with_families includes them only when Family::Flagd is 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:

OperatorMeaning
=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:

  1. Strip leading v / V: "v1.2.3", "V1.2.3", and "1.2.3" are all equivalent.
  2. Pad partial versions: "1" becomes "1.0.0", "1.2" becomes "1.2.0".
  3. Coerce numeric input: 1 (a JSON number) is treated as the string "1", then padded.
  4. 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

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:

TierAPI Entry PointWhen to use
Tier 0eval_str, eval, eval_into, compileQuick scripts, one-off execution
Tier 1Engine::eval*Custom operators, non-default configs, templating mode
Tier 2Engine::session() + Session::eval*Hot loops (APIs, message queues, bulk pipelines); reuses internal bump arenas
Tier 3Engine::evaluate(&Logic, data, &Bump)Zero-copy evaluation with a caller-owned bumpalo::Bump arena
Tier 4Engine::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

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.

TierEntry pointArena ownerReturnsUse when
0datalogic_rs::eval_str / eval / eval_into / compilelazy static EngineString / OwnedDataValue / T / LogicOne-shot scripts, ad-hoc evaluation, no custom config
1Engine::eval_str / eval / eval_into / eval_asper-call BumpString / OwnedDataValue / TYou need custom operators, config, or templating mode
2Engine::session() → Session::eval*session-owned Bumpowned or &DataValue<'a>Hot loops, services, batch jobs
3Engine::evaluate(&Logic, data, &Bump)caller-owned Bump&'a DataValue<'a>Zero-copy result pipelines, custom pool strategies
4Engine::trace() → TracedSession::*per-call Bump (caller-owned for eval_borrowed) + trace bufferTracedRun<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
MethodNotes
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 in Arc to share across threads (or use Engine::compile_arc to do it in one step).
  • Immutable after construction.
MethodReturns
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.

ImplementorCost
&'a DataValue<'a>Pass-through.
DataValue<'a>One arena alloc.
&'a strJSON parse via DataValue::from_str; unescaped strings borrow from the text.
&'a StringJSON parse, same as &str.
&OwnedDataValueViewed in place: array and object spines built in the arena, leaves borrowed.
&'a ParsedDataPass-through: the tree is already arena-resident.
&serde_json::Value (feature = "serde_json")Viewed in place, like an owned value.
&Roots / RootsOne 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(()) }
}
ParameterNotes
argsPre-evaluated arguments. The engine has already recursed into each arg’s expression tree.
ctxOpaque view into the engine’s evaluation context. Untouched by most operators.
arenaAllocator 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): charge n operations against the operation budget before doing work the node count does not reflect. Without the budget feature it returns Ok(()), 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.

TypeRole
CheckModeEngine, Strict or Template: the mode Engine::check reads a rule in.
DiagnosticOne problem: code, severity, message, pointer (RFC 6901), operator. Diagnostic::error(msg) / warning(msg) / at_argument(i) build one from a custom operator’s check.
DiagnosticCodeUnparsable, UnknownOperator, NotAnOperator, ArgumentForm, ArgumentCount, InvalidTimezone, SimilarToOperator, Compile, OperatorCheck.
SeverityError or Warning.
CompileErrorWhat compile_checked returns: diagnostics: Vec<Diagnostic>. Implements std::error::Error.
Facts / DataPathWhat Logic::facts returns. See Facts.
OperatorInfo / ScopedArgWhat 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 — EvaluationConfig and EngineBuilder options
  • Custom Operators — implementing CustomOperator and ArenaExt
  • Rule Analysis — Engine::check, compile_checked, Logic::facts, and Engine::operators
  • Thread Safety — sharing Engine and Logic across 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-wasm is 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:

PlatformArchitectures
Linux (glibc)x64, arm64
Linux (musl)x64, arm64
macOSx64, arm64
Windowsx64, 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.

  • templateKeyEscape is a single-character prefix, unset by default, that lets a template emit a key which would otherwise run as an operator: with new Engine({ templating: true, templateKeyEscape: '$' }), { $type: { var: 'x' } } yields { type: 1 } rather than running the type operator, and { $$type: 1 } yields { $type: 1 }. Anything other than a one-character string throws errorType: 'InvalidArguments'. See Structured Objects.
  • families keeps 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: true makes 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

SymbolDescription
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.allocatedBytesParse 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

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-node package (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:

TargetUse CaseInit Required
webBrowser ES Modules, CDNYes (await init())
bundlerWebpack, Vite, RollupNo (instantiates on import; needs the bundler’s WASM ESM integration)
nodejsNode.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 CommonJS nodejs target, so a default import binds init to the module namespace object and await init() throws TypeError: 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-op

Or import from @goplasmatic/datalogic-wasm/nodejs and skip init.

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

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

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 compiled WebAssembly.Module). Defaults to datalogic_wasm_bg.wasm next 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 init is not a function there. The package’s node export condition resolves the bare specifier to the CommonJS nodejs target, so a default import (import init from '@goplasmatic/datalogic-wasm') binds init to the module namespace object and await init() throws TypeError: 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 Node

Or import the Node target (@goplasmatic/datalogic-wasm/nodejs) and skip init.


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 / compileStrict choose 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 key type instead of running the type operator, and {"$$type": ...} emits a literal $type. Only meaningful in templating mode; anything other than a one-character string throws. See Structured Objects
  • customOperators - 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/undefined count as JSON null). 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 configuration
  • strictOperatorNames - When true, a custom operator named like a built-in (aliases included) throws a ConfigurationError instead of being registered and never running. Defaults to false.
  • families - The operator families the engine has besides the JSONLogic core: "DateTime", "ExtString", "ExtArray", "ExtObject", "ExtControl", "ErrorHandling", "ExtMath", "Tensor", "Flagd" (the family of each operators() 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 with InvalidOperator, and errors in check), 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 in steps, 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: 0 with an empty expression is only the placeholder returned when compilation itself fails.
  • pointers places 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.
  • result keeps 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 TracedResult JSON layout is the JavaScript-side wire shape, shared by every binding and described by schemas/trace.v1.json in the repository. On the Rust side it is produced from a datalogic_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:

FieldMeaning
name, aliasesCanonical name and the other keys that call it (val / ["var"])
family, featureOperator family ("Core", "DateTime", …) and the Cargo feature that gates it (null for the core)
min_args, max_argsArgument counts the operator reads (max_args: null is unbounded)
reads_contextWhether it reads the data context
effect"pure", "clock" (now), "throws" or "catches"
costWhat its work is proportional to: "node", "bytes", "per_item", "n_log_n", "quadratic", "elements"
scoped_argWhich 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 string
  • evaluateData(handle: DataHandle): string - evaluate against a DataHandle
  • evaluateMetered(data: string, budget?: number): string - evaluate under an operation budget, returning {"result": ..., "ops": N} as for evalMetered
  • facts(): 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 }
FieldMeaning
readsData 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_readsA path is only known at runtime ({"var": {"cat": [...]}})
reads_completereads is everything the rule can read: no computed path, no custom operator
reads_dataThe result depends on the data at all
operatorsCanonical built-in names the rule uses (val for var), matching engine.operators()
custom_operatorsCustom operator names the rule calls
deterministicfalse 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 arena
  • evaluateData(rule: Rule, handle: DataHandle): string - the hot path: arena reuse and no per-call data work
  • evaluateMetered(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 an Error named TypeMismatch (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 throws TypeMismatch
  • evaluateFloat(rule: Rule, handle: DataHandle): number - accepts any JSON number; otherwise throws TypeMismatch
  • evaluateTruthy(rule: Rule, handle: DataHandle): boolean - collapses any result through the engine’s truthiness rules (the same coercion if/and/or apply); never type-mismatches
  • evaluateBatch(rule: Rule, handles: DataHandle[]) - one rule against many handles
  • evaluateMany(rules: Rule[], handle: DataHandle) - many rules against one handle (the rule-set / feature-flag shape)
  • reset(): void - reset the arena (optional; every evaluate* call resets first)
  • allocatedBytes(): number - bytes held by the arena’s chunks
  • free(): 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.

KeyValue
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_errorsboolean
missing_var"null" (default: a missing variable reads as null) | "error" (throws VariableNotFound)
truthy_evaluator"javascript" | "python" | "strict_boolean"
numeric_coercionobject of booleans: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric
max_recursion_depthinteger >= 1
ops_budgetinteger >= 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:

PropertyContents
nameStable error-kind tag: "ParseError", "InvalidOperator", "InvalidArguments", "VariableNotFound", "Thrown", "IndexOutOfBounds", "TypeError", "ConfigurationError", "BudgetExceeded", "Custom", … plus "TypeMismatch" from the typed session methods and "CompileError" from compileChecked
messageHuman-readable message, including (in operator: ...) when the failing operator is known
typeSame tag as name (mirrors the wire JSON)
operatorInnermost failing operator, custom operators included (runtime errors only)
node_idsBreadcrumb of compiled-node ids from the failure site toward the root (runtime errors only)
variant extrasKind-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")
detailJsonThe 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 mode
  • CompileError: compileChecked found an error diagnostic; diagnostics lists them
  • InvalidOperator: an unknown operator name (raised at evaluation, not at compile), or a multi-key object when templating is off
  • Thrown: an explicit throw, or arithmetic that produced NaN ({"+": ["abc", 1]})
  • InvalidArguments: wrong argument shape for an operator (for example an unknown date_diff unit)
  • VariableNotFound: a missing variable on an engine configured with missing_var: "error"
  • ConfigurationError: an unknown config key or value, an unknown family, or a refused custom operator name
  • BudgetExceeded: the evaluation charged past its operation budget
  • TypeMismatch: session.evaluateBool / evaluateInt / evaluateFloat got 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.

DeprecatedUse 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

  1. 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));
    }
    
  2. 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
    
  3. Reuse Rule instances, 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();
    
  4. Parse a payload once when it feeds several evaluations: a DataHandle plus a Session skips the per-call copy and parse; on kilobyte payloads that is most of the round trip.

Next Steps

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.parse creates a new result reference on every evaluation. Keying useEffect on logicJson and dataJson ensures the rule only recompiles when its JSON text changes and prevents infinite render loops (“Maximum update depth exceeded”).
  • Free compiled rules explicitly: every Rule holds WASM linear memory. The hook calls free() whenever it replaces a compiled rule and again on component unmount, rather than waiting for garbage collection. Because the Engine is 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

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

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 Python dict or list and walks the Python objects straight into the engine’s arena (it falls back to pythonize only for unusual types such as subclasses and sets). In the boundary benchmark this is about 3 to 6 times faster than a json.dumps → evaluate_str → json.loads round 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

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.evaluate inside a ThreadPoolExecutor or a threading.Thread, the evaluations run concurrently on separate CPU cores inside the Rust engine.
  • Sharing: Share one Engine and the compiled Rule objects across all threads. Keep Session objects 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:

TierEntry pointUse when
Data handleDataHandle(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
Typedsession.evaluate_bool/int/float/truthy(rule, data)Predicates and scalar results, no result conversion on the way out
Batchsession.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):

CallReturns
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

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".

KeyValues
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_errorsbool
missing_var"null" (default: a missing variable reads as None), "error" (raises VariableNotFound)
truthy_evaluator"javascript", "python", "strict_boolean"
numeric_coercionobject of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric
max_recursion_depthinteger >= 1
ops_budgetinteger >= 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

ArgumentEffect
templating=TrueCompile 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=TrueRaise 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:

ExceptionWhen
DataLogicErrorBase class; catch this for “anything from datalogic”
ParseErrorMalformed rule or data JSON, or an unsupported Python type in the dict path
EvaluateErrorEverything else the engine reports: runtime operator failures, unknown operators ("InvalidOperator"), invalid configuration ("ConfigurationError")
CompileErrorcompile_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", …), or None.
  • .node_ids: a leaf-to-root breadcrumb of compiled-node ids.
  • .path: a root-to-leaf list of step dicts, each with node_id, operator, arg_index, and json_pointer; None when 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 edge
  • decimal.Decimal: convert to float or str
  • bytes, 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

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:

OSArchitectureSubdirectory
Linuxamd64linux_amd64/
Linuxarm64linux_arm64/
macOSamd64 (Intel)darwin_amd64/
macOSarm64 (Apple Silicon)darwin_arm64/
Windowsamd64windows_amd64/
Windowsarm64windows_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.mod directive; older toolchains with GOTOOLCHAIN=auto download it on demand).
  • A C compiler (e.g. gcc or clang / Xcode command line tools) to link the static library during go 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 build in bindings/go).

Next Steps

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

Concurrency & Sessions

datalogic-go exposes the Rust core’s thread-safety rules as goroutine-safety rules.

Concurrency Model

  • Engine: Goroutine-safe (Send + Sync in Rust). Construct a single Engine and share it across goroutines.
  • Rule: Goroutine-safe. Compile a rule once, and call rule.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 with datalogic.ParseData(), share it across goroutines (evaluation only reads it), and Close() it after the last use.
  • TracedSession: Goroutine-safe. Every Evaluate() uses a fresh internal arena, so one traced session can serve many goroutines.
  • EngineBuilder: Not goroutine-safe. Build on one goroutine, then share the resulting Engine.

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

TierEntry pointUse when
Compile onceengine.Compile(rule) then rule.Evaluate(data) / rule.EvaluateData(handle)Same rule evaluated against many data inputs
Compile modeengine.CompileTemplate(rule) / engine.CompileStrict(rule)One rule in a templating mode other than the engine’s
Checkedengine.Check(rule, mode) / engine.CompileChecked(rule)Every problem in a rule before it runs, with JSON Pointers (Rule Analysis)
Introspectionrule.Facts() / engine.Operators() / engine.Truthy(value)What a rule reads and calls, the operator catalogue, the engine’s truthiness
Sessionengine.Session() then session.Evaluate(rule, data) / session.EvaluateData(rule, handle)Hot loops: arena reuse per goroutine
Meteredsession.EvaluateMetered(rule, data, budget)The operations an evaluation charges, optionally capped (budget 0 is the engine’s own)
Data handledatalogic.ParseData(json)Same payload evaluated many times: parse once, zero parse work per call
Typedsession.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)
Batchsession.EvaluateBatch(rule, handles) / session.EvaluateMany(rules, handle)Many evaluations per native call, with per-item errors in each BatchResult.Err
Tracedengine.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

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 an error on invalid config.
  • b.Templating(on): toggle templating mode.
  • b.TemplateKeyEscape(r): a template key starting with rune r becomes a literal output field (with '$', {"$type": ...} emits type).
  • b.Families(names...): keep the engine to the JSONLogic core plus the operator families named (see Operator Families).
  • b.StrictOperatorNames(on): make Build refuse a custom operator named like a built-in, instead of registering one that never runs. Call it before AddOperator.
  • 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.

KeyValues
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_errorsbool
missing_var"null" (default: a missing variable reads as null), "error" (fails with VariableNotFound)
truthy_evaluator"javascript", "python", "strict_boolean"
numeric_coercionobject of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric
max_recursion_depthinteger >= 1
ops_budgetinteger >= 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:

FieldContents
MessageHuman-readable error string
TypeThe engine’s stable error tag; match on this for programmatic handling
OperatorInnermost failing operator name ("+", "var", …); empty when the failure didn’t originate inside a named operator
PathJSONJSON 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
NodeIDsJSONThe compiled-node breadcrumb, leaf to root, as a JSON array of ids
DiagnosticsJSONType == "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

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
}
MethodReturns
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

  • Engine and Rule instances are thread-safe; you can share them globally.
  • Session instances 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 shared java.lang.ref.Cleaner frees a handle you never close, including an EngineBuilder dropped before build().
  • Rules and sessions keep their engine’s custom operators alive, so they keep working after engine.close().

Going deeper

.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
}
MethodReturns
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

  • Engine and Rule instances are thread-safe; you can share them globally.
  • Session instances are not thread-safe; keep each one local to a single thread.
  • The native-handle types (Engine, Rule, Session, TracedSession, DataHandle) implement IDisposable. 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.
  • EngineBuilder is not disposable. Build() releases its native builder, and a finalizer frees a builder you drop before Build().
  • Rules, sessions and traced sessions keep the engine’s custom-operator callbacks alive, so custom operators keep working after the Engine is disposed or finalized.

Going deeper

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
}
MethodReturns
$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: 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.

EcosystemPackagingBinaries LayoutLoading Mechanism
GoGo ModuleStatic libraries in lib/<os>_<arch>/cgo static linking at compile time
JVMMaven JARShared libraries at the classpath root under <os-arch>/FFM (java.lang.foreign) at runtime
.NETNuGetShared libraries under runtimes/<rid>/native/P/Invoke LibraryImport at runtime
PHPComposerShared 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 / TypeThread-Safe?Usage Pattern
EngineYesConstruct once globally; share across all threads/goroutines.
RuleYesCompile once; share and call Evaluate() concurrently.
SessionNoNever share sessions. Keep one Session instance per thread.
TracedSessionYesOpen once; evaluate concurrently.
DataHandleYesParse once; share across threads and engines (evaluation only reads it); close after the last evaluation.
EngineBuilderNoConfigure 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

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:

PackageVersion rangePurpose
react^18.0.0 || ^19.0.0React framework
react-dom^18.0.0 || ^19.0.0React DOM renderer
@xyflow/react^12.0.0Flow 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

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

BehaviorEnabled byDescriptionRequires data
Read-only(none)Static diagram visualizationNo
DebuggerdataStep-through execution trace with a step timeline and failure highlightingYes
EditingeditableVisual builder: node selection, properties panel, context menus, undo/redoNo
TemplatingtemplatingMulti-key objects and arrays become output-shaping templatesNo
Engine settingsconfigEvaluation semantics: presets, NaN and division-by-zero handling, truthiness, coercion, recursion cap, operation budgetNo
Custom operatorscustomOperatorsExtra operators registered on the engineNo

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-direction on the .logic-editor root

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_ids in 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

AspectRead-onlyDebugger (data)Editing (editable)
Node displayStructure onlyStructure, plus values on the current stepEditable nodes
InteractivityPan/zoomPan/zoom + steppingFull editing
data requiredNoYesNo
OutputStaticStatic + traceTwo-way bound via onChange

Performance Considerations

  • Read-only is fastest: no evaluation overhead.
  • Debugger re-runs the trace when the content of data changes. A new object with the same content does not re-run it.
  • Editing rebuilds the expression on each change (debounced before onChange fires).

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 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 (null yields an empty result)
  • options.templating: when true, 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 nodes
  • edges: optional array of edges. When omitted, the function derives edges from the node relationships
  • direction: '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 as data-direction on the .logic-editor root.

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

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.

ParameterWhat it is
argsThe operator’s arguments already evaluated by the engine. Each &'a DataValue<'a> borrows from caller input or from earlier arena allocations.
ctxOpaque 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.
arenaThe 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, and reads_complete() for one that does not read the context.
  • Argument count. A call outside the declared count fails with InvalidArguments before any argument is evaluated, and Engine::check reports it as an ArgumentCount error.

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

  1. Declare the argument count in info, so a bad call fails before its arguments run and check reports it; validate types early in evaluate.
  2. Allocate results in the arena (arena.alloc(...) / arena.alloc_str(...)).
  3. Return meaningful errors: Error::invalid_arguments, Error::type_error, Error::custom_message, Error::wrap.
  4. Keep operators focused: one responsibility per operator.
  5. Use Arc for shared configuration to maintain Send + Sync.
  6. 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::info and check integrate with static analysis
  • Operation Budget — charge custom operator work with ctx.charge(n)
  • Rust API Reference — full signatures for CustomOperator, EvalContext, and ArenaExt

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" })
IgnoreValue3 (skips "text")6 (skips "x")
CoerceToZero3 ("text" becomes 0)0 ("x" becomes 0)
ReturnNullnullnull

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]}:

SettingResult
ReturnSaturated (default)1.7976931348623157e308 (f64::MAX, sign of dividend)
ThrowErrorErr(Thrown { type: "NaN" })
ReturnNullnull
ReturnInfinityf64::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:

ValueJavaScriptPythonStrictBoolean
truetruthytruthytruthy
falsefalsyfalsyfalsy
1truthytruthytruthy
0falsyfalsytruthy
""falsyfalsytruthy
"0"truthytruthytruthy
[]falsyfalsytruthy
[0]truthytruthytruthy
{}falsyfalsytruthy
nullfalsyfalsyfalsy

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.

KeyValue
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_errorsbool
truthy_evaluator"javascript", "python", or "strict_boolean"
numeric_coercionobject of bools: empty_string_to_zero, null_to_zero, bool_to_number, reject_non_numeric
max_recursion_depthinteger >= 1
ops_budgetinteger >= 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

Rule Analysis

You can inspect a rule without evaluating it, since 5.8.0:

  • Engine::check lists every problem the engine can see before the rule runs, and Engine::compile_checked refuses a rule that has any error.
  • Logic::facts reports which data paths a compiled rule reads, which operators it calls, and whether its result depends on anything besides its data.
  • Engine::operators describes every built-in operator the engine evaluates, and operators.json is 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:

FieldMeaning
codeA DiagnosticCode (table below).
severitySeverity::Error: the rule fails to compile, or fails whenever this part runs. Severity::Warning: it runs, but probably not as meant.
messageOne sentence naming the operator involved.
pointerAn RFC 6901 JSON Pointer into the rule, to the object or argument at fault. "" is the whole rule.
operatorThe operator (or key) the problem is about, when there is one.

Its Display form is <severity> at <pointer>: <message>, with / for the whole rule.

DiagnosticCodeSeverityReported for
UnparsableerrorThe rule is not valid JSON.
UnknownOperatorerrorA single-key object whose key no operator of this engine answers to, with a “did you mean” suggestion one edit away.
NotAnOperatorerrorAn object with several keys outside templating mode.
ArgumentFormerrorand, or or if given one argument instead of an array.
ArgumentCounterror / warningAn 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).
InvalidTimezoneerrorA literal timezone name that is not in the timezone database.
SimilarToOperatorwarningIn a template, an output key one edit away from an operator name.
CompileerrorAny other reason the compiler rejects the rule, such as nesting deeper than 256 levels.
OperatorCheckas returnedA 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:

ModeReads the rule as
CheckMode::EngineThe engine’s own mode, as compile would.
CheckMode::StrictOutside templating mode, as compile_strict would.
CheckMode::TemplateIn 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:

MethodReturns
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 try catch 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 one map) is listed.
  • Covered reads are dropped. Reading user observes user.name, so only user is 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’s Display joins segments with dots, so use segments() when keys may contain them. is_root() tests for the empty path and a.covers(&b) for an ancestor.
  • Complete or a lower bound. A computed path ({"var": {"var": "key"}}) sets has_computed_reads(), and a custom operator can read the whole context through EvalContext::root_input unless its info says otherwise. Either makes reads_complete() false, and reads() 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 only a. An engine built with_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.

FieldMeaning
nameCanonical name ("val", "if", "switch").
aliasesOther names the compiler accepts ("var", "?:", "match").
family"Core", "DateTime", "ExtString", … (Family::name()).
featureThe Cargo feature that gates the family, or None for the core.
min_args, max_argsThe 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_contextWhether it reads the data context (val, missing, missing_some, exists, fractional).
effect"pure", "clock" (now), "throws" (throw) or "catches" (try).
costWhat its work is proportional to: "node", "bytes", "per_item", "n_log_n", "quadratic" or "elements".
scoped_argThe 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.

BindingEquivalents
Nodeengine.check(rule, mode?), engine.compileChecked(rule) (throws errorType: "CompileError" with diagnostics), rule.facts(), engine.operators(), as JS values.
WASMengine.check(logic, mode?), engine.compileChecked(logic) (throws an Error named CompileError with diagnostics), rule.facts(), engine.operators(), as JSON strings.
Pythonengine.check(rule, mode=None), engine.compile_checked(rule) (raises CompileError with .diagnostics), rule.facts(), engine.operators(), as typed dicts.
Goengine.Check(ruleJSON, datalogic.ModeEngine), engine.CompileChecked(ruleJSON) (an *Error with Type "CompileError" and DiagnosticsJSON), rule.Facts(), engine.Operators().
JVMengine.check(ruleJson, CompileMode.ENGINE), engine.compileChecked(ruleJson) (DatalogicException with diagnosticsJson()), rule.facts(), engine.operators().
.NETengine.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 ABIdatalogic_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

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:

ChargedAmount
Each node the engine dispatches1
Each item an iterator examines (map, filter, reduce, the quantifiers, sort, …), over an array or an object1 per item or key, charged as soon as the source resolves
Each item a collection operator copies, examines or compares1 per item, charged before the work (see below)
Each whole 64 bytes of string a string operator reads1 (see below)
Each tensor operatormax(elements read, elements produced)
A custom operatorwhatever 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:

OperatorCharge on top of its node
merge1 per item of each array argument (scalars and null cost nothing extra)
in with an array haystackthe haystack’s length, whether or not the needle is found
missing, missing_some with a path list from data1 per path (literal paths cost nothing extra)
keys, values, entries1 per key
slice of an array with a step other than 11 per item produced (a contiguous slice borrows its source and costs nothing extra)
distinct, group_by1 per comparison: each item is compared with every value or group kept so far, so n distinct values cost n(n-1)/2
sortn·⌈log₂ n⌉ comparisons
+, -, *, /, % over one array argument1 per item
==, ===, !=, !==, in, switch comparing two arrays or two objects1 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:

OperatorBytes charged
cateach 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 stringthe input string
in with a string haystackthe haystack
starts_with, ends_withthe shorter of the string and the prefix or suffix
splitthe 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
BindingPer-call meteringReturns
Nodeengine.evalMetered(rule, data, budget?), rule.evaluateMetered(data, budget?){ result, ops }, result as JSON text
WASMengine.evalMetered(..), rule.evaluateMetered(..), session.evaluateMetered(rule, data, budget?)JSON text {"result": .., "ops": ..}
Pythonengine.eval_metered(rule, data, budget=None), rule.evaluate_metered(data, budget=None)(result_json, ops)
Gosession.EvaluateMetered(rule, dataJSON, budget)(string, uint64, error)
JVMsession.evaluateMetered(rule, dataJson, budget)Metered(value, ops)
.NETsession.EvaluateMetered(rule, dataJson, budget = 0)MeteredResult(Value, Ops)
PHP$session->evaluateMetered($rule, $dataJson, $budget = 0)['value' => .., 'ops' => ..]
Cdatalogic_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

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: ?? needs ext-control and length needs ext-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 keyOutput key
$typetype
$$type$type
$$$type$$type
$foofoo
typenot 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 key a twice. The engine keeps duplicate pairs (as keys / values / entries do), and so does the JSON text eval_str returns; converting the result to a serde_json::Value keeps 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

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: Session does not reset itself. Call session.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

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

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 now operator (see Determinism below),
  • reach outside the data you provide: var / val resolve against the input value and the active iteration scope only,
  • mutate the input, the engine, or shared state: evaluation takes &self and 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

BoundDefaultWhat it protects
JSON parse depth256Parsing a rule or data string cannot overflow the stack. Deeper input is a ParseError.
Compile nesting depth256A 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_depth256Caps 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 nesting1,024 levelsA 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 size2^28 elementszeros, 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_budgetunsetCaps 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:

  1. 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.
  2. Set an operation budget. This control survives a rule designed to be expensive over input you thought was small enough.
  3. Bound rule complexity. For user-authored rules, cap the serialized rule size, and lower max_recursion_depth if 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_budget unset. 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 run Engine::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 throw should surface: a thrown error is a normal Result::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::Error if a misspelled path should fail instead of reading as null.
  • 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

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

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

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 / errors branch need templating mode. Compile the rule as a template: in Rust, Engine::compile_template (with the templating Cargo feature) or an engine built with with_templating(true); in the bindings, compileTemplate (compile_template in Python, CompileTemplate in Go and .NET) or the templating engine 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 error and valid branches 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 / priority branches 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

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:

  1. Compilation (slower): Parse and optimize the JSONLogic expression
  2. 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 OpCode that 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 a bumpalo::Bump for one evaluation. Read-through ops like var borrow zero-copy from the caller’s input.
  • Reusable arenas: Session reuses one Bump across calls; you call session.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:

OperatorBody shape
filter, all, some, nonea 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
mapa 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
sorta 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 handBest entry point
JSON strings, no engine configdatalogic_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 objectSession::eval*(&compiled, &Roots::from([..]))
A session stored in a struct, moved across threads or held across .awaitSharedSession::new(Arc<Engine>)
Borrowed result, session-owned arenaSession::eval_borrowed(&compiled, data)
Hot path, owns the BumpEngine::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:

  1. Memoise expressions:
    const expression = useMemo(() => ({ ... }), [deps]);
    
  2. Debounce data changes when debugging: providing data enables the debugger, so debounce it to avoid re-evaluating on every keystroke.
    const debouncedData = useDebouncedValue(data, 200);
    <DataLogicEditor value={expr} data={debouncedData} />
    
  3. Omit data when 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

  1. Pre-compile all rules at startup
  2. Use a worker pool with per-worker Sessions for parallel evaluation
  3. Monitor evaluation latency in production
  4. 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.
  5. 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

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.

Dimensiondatalogic-rsjson-logic-jsjson-logic-enginejsonlogic-rsGoRules ZENCEL
FormatJSONLogic (JSON data)JSONLogic (defines the standard)JSONLogic supersetJSONLogicProprietary JDM + Zen expression languageCEL expression grammar (not JSON)
LanguagesOne core, official bindings for Rust, Node, WASM, Python, Go, Java, .NET, PHPJS core; other languages are separate community portsJS/TS onlyRust (single crate, Python/WASM wrappers exist but stale)Rust core with several bindingsGo/Java mature, others varying
Standard compliancePasses the official JSONLogic test suite, plus opt-in extensionsThe reference implementationSuperset with minor deviationsPasses core suiteNot JSONLogicOwn spec
SandboxingNo eval, no I/O, core forbids unsafe codeNo evalNo evalNo evalFunction nodes execute JavaScript (QuickJS)Non-Turing-complete, strong formal story
ToolingReact visual editor, step-through trace debugger, online playground, rule checker (check)Play pageNone officialNone officialJDM editor + commercial BRMSCommunity playgrounds
ExtensibilityCustom operators per host languageCustom ops in JSCustom ops (incl. async) in JSLimitedCustom nodesExtension 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 fractional operator 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:

RuntimeOne-line call
Rustdatalogic_rs::eval_str(rule, data)?
Node.jsapply(rule, data)
Browser (WASM)new Engine().evalStr(rule, data)
Pythonapply(rule, data)
Godatalogic.Apply(rule, data)
Javaengine.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

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:

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_date with a format specifier chrono does not know ("%Q") or a trailing % is an InvalidArguments error 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, distinct and type.
  • Integers above 2^53 compare, divide and sort exactly in ==, ===, < and the other comparisons, /, % and sort; they are no longer rounded or judged equal.
  • sort by a field reads a missing field as null, so it ties with a null field.
  • filter with === / !== against null treats a missing field as null, 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, and abs, ceil and floor raise InvalidArguments.
  • NanHandling::CoerceToZero substitutes 0 instead of skipping the value: {"*": [2, "x", 3]} is 0, not 6.
  • Variadic + and * coerce numeric strings as the two-argument forms do.
  • ceil and floor return a float for a result outside the i64 range instead of saturating.
  • lower and upper apply context-sensitive Unicode case rules ("ΟΔΟΣ" lowers to "οδος").
  • type classifies 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 +, *, max and min, a var path built with cat) gives the same result with or without constant folding: {"+": [{"if": [true, [1, 2], 0]}]} is 3 with 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 dotted val path stays in val form, and strings are escaped.

Limits

  • A reduce accumulator may nest at most 1,024 levels; deeper is an InvalidArguments error instead of a stack overflow.
  • Tensor constructors (zeros, full, scatter, rle_expand, one_hot, pad) refuse more than 2^28 elements.
  • fractional clamps bucket weights to i32::MAX, as flagd does.
  • Operation counts from evaluate_metered rise for iterator shapes that took a fast path, to match the general path.

Deprecations (removed in 6.0)

  • WASM: the CompiledRule class and the free evaluate and evaluateWithTrace functions. Build an Engine and use compile, evalStr and evaluateWithTrace on it.
  • Node and WASM: evaluateNumber. Use evaluateFloat (or evaluateInt).

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. Evaluator is gone (args arrive pre-evaluated).
  • Method renames. The evaluate_* methods become eval_*. evaluate_str → eval_str, evaluate_borrowed → eval_borrowed. The serde_json::Value-shaped variants (evaluate_json_value, evaluate_owned, evaluate_ref, …) collapse into one typed entry point: engine.eval_into::<T, _, _>(rule, data) (or datalogic_rs::eval_into::<T, _, _>(...) at the module level), gated on feature = "serde_json".
  • Builder construction. DataLogic::with_config(c) / with_preserve_structure() / with_config_and_structure(c, s) all collapse into Engine::builder() with .with_config(c) and .with_templating(s) setters.
  • Compilation accepts more shapes. engine.compile(rule) takes any IntoLogic: &str, &String, &OwnedDataValue, OwnedDataValue, &serde_json::Value (gated on serde_json).
  • Module-level helpers for one-shot calls. datalogic_rs::eval, datalogic_rs::eval_str, datalogic_rs::eval_into and datalogic_rs::compile use a shared default engine, so a one-shot call needs no Engine.
  • Sessions are explicit. Reusable arenas live on Session (engine.session()); the session never auto-resets, so you call session.reset() between batches.
  • Trace surface is a session. engine.trace().eval_str(rule, data) returns a TracedRun<R> with result: Result<R, Error> plus steps and expression_tree. Available on feature = "trace". The old TracedResult type is gone; successful and failed runs share the same TracedRun<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. Engine is immutable after build(). Register every custom operator on the EngineBuilder before calling .build().
  • Error is structured. Error is a struct with kind, operator(), node_ids(), tag() and code(), plus a stable JSON wire format. Construct via Error::invalid_arguments(...), Error::type_error(...), Error::custom_message(...), Error::wrap(...).
  • preserve operator removed. Literal scalars and arrays pass through inline; 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").
  • Edition 2024 + #![forbid(unsafe_code)].

Feature-flag rename

The pre-release compat feature is gone. The replacement is purpose-named:

v4 / pre-release featurev5 featureWhat it enables
compat (mixed interop + shims)serde_json&serde_json::Value interop and the typed eval_into::<T> paths
preservetemplatingTemplating mode and Engine::builder().with_templating(true)
tracetraceengine.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


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:

  1. Check the API Reference
  2. Review the FAQ and Troubleshooting Guide
  3. Review the examples
  4. 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 to false, such as an object compared to a number. For the json-logic-js behavior, set loose_equality_errors to false.
  • Division by zero. A fractional dividend over zero returns ±f64::MAX by default (ReturnSaturated; ReturnNull, ThrowError and ReturnInfinity are the other division_by_zero modes). An integer divided by an integer zero raises Thrown {"type": "NaN"} in every mode.
  • Empty objects. {} is falsy, like []; json-logic-js treats it as truthy. In Rust, a TruthyEvaluator::Custom closure can restore the json-logic-js rule.
  • Large integers. Integers keep their i64 value, 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

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: Logic is Send + Sync; wrap it in Arc to share it
  • Extended operators: datetime, string, array and object helpers, try / throw, flagd feature-flag operators
  • Rule checking: check reports 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?

MethodInputOutputNotes
datalogic_rs::eval_str (and eval / eval_into)R: IntoLogic, D: OwnedInputString (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: OwnedInputString (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: EvalInputString (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:

  1. Fix the operator name (operators are case-sensitive)
  2. Register a custom operator on the builder
  3. 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

  1. Use a Session for repeated calls, so they reuse one arena
  2. For the hottest path, call Engine::evaluate with a bumpalo::Bump you manage
  3. 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

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:

  1. Check the operator name spelling (operators are case-sensitive).
  2. Register a custom operator on the builder.
  3. 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:

  1. Check the variable path spelling.
  2. Give the read a default: a read with a default is not a miss.
  3. Test with missing or exists first (they never raise this error), or wrap the read in try.
{"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) (or datalogic_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)? (requires feature = "serde_json"; Engine::eval_into takes a rule source, not a compiled &Logic)
    • engine.evaluate_json_with_trace(rule, data) → engine.trace().eval_str(rule, data) returning TracedRun<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:

  1. Check that your bundler copies the .wasm file into the build output.
  2. 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).
  3. 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:

  1. Container has no dimensions
  2. CSS not imported
  3. 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:

  1. Check existing issues
  2. Create a minimal reproduction
  3. 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::check and compile_checked
  • Configuration — configure MissingVar, NanHandling, and DivisionByZeroHandling
  • Migration Guide — upgrading from v4 or earlier 5.x versions