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 1,565-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 right now, open the playground.

What is JSONLogic?

JSONLogic is a standard for expressing logic rules as JSON. This makes it:

  • Portable: Rules can be stored in databases, sent over APIs, or embedded in configuration
  • Language-agnostic: The same rules work across different implementations
  • Human-readable: Rules are easier to understand than arbitrary code
  • Safe: Rules can be evaluated without arbitrary code execution

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 if age > 18 AND country == "US".

How the engine works

datalogic-rs uses a two-phase approach:

  1. Compilation: Your JSON logic is parsed and compiled into a reusable Logic. This phase:

    • Assigns OpCodes to built-in operators for fast dispatch
    • Pre-evaluates constant expressions
    • Analyzes structure for templating mode
  2. Evaluation: The compiled logic is evaluated against your data with:

    • Direct OpCode dispatch (no string lookups at runtime)
    • Arena-allocated results that can borrow zero-copy from the input
    • A context stack for nested operations (map, filter, reduce)

Compile once, evaluate many: that is the pattern every binding exposes, and the reason evaluation runs in nanoseconds.

Find your language

Every language has a first-class chapter with install, quickstart, and the API surface:

Your stackStart here
RustRust (native crate)
Node.js servicesNode.js (native)
Browser, edge, Deno, BunJavaScript (WASM)
PythonPython
GoGo
Java, Kotlin, ScalaJava / Kotlin (JVM)
.NET (C#, F#).NET
PHPPHP
Another language entirelyC ABI
React rule-builder UIReact Visual Debugger

How these docs are organized

Next steps

Playground (inline)

Want the full experience? Try the Full-Page Visual Editor with examples and resizable panels. This page embeds the same widget inline for quick checks.

Try JSONLogic expressions right in your browser! This playground uses the visual debugger component powered by 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. Diagram: View the visual diagram of your logic expression
  4. Examples: Use the dropdown to load pre-built examples

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

Select your target language to see package installation instructions:

// Cargo.toml
[dependencies]
datalogic-rs = "5.1"

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.1.0</version>
</dependency>

// Gradle: build.gradle.kts
implementation("io.github.goplasmatic:datalogic:5.1.0")
// dotnet CLI
dotnet add package Goplasmatic.Datalogic
// Composer
composer require goplasmatic/datalogic

Note for Rust users: v5 does not require serde_json by default — the canonical entry points (Engine::eval_str, Engine::compile(&str), datalogic_rs::eval_str) are string-based. Add the serde_json feature only if you need serde_json::Value interop or the typed eval_into::<T> paths.

Feature Flags

v5 splits the surface into a small core plus opt-in features:

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::builder().with_templating(true).build().
datetimeoffdatetime, timestamp, parse_date, format_date, date_diff, now operators (pulls in chrono).
traceoffPer-evaluation execution tracing (engine.trace()…). Transitively enables serde_json.
ext-stringoffExtended string operators.
ext-arrayoffExtended array operators (e.g. sort).
ext-controloffExtended control-flow operators (exists, ??, switch/match, type).
error-handlingofftry / throw operators.
ext-mathoffExtended math operators.
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. Opt-in on purpose: it forwards to chrono/wasmbind, whose JS imports fail to instantiate in non-JS wasm runtimes such as wasmtime, wazero, and Chicory — leave it off there (on WASI the OS clock works without it).

Example — opt into serde_json::Value interop plus templating:

[dependencies]
datalogic-rs = { version = "5.1", features = ["serde_json", "templating"] }
serde_json = "1.0"

Version Selection

  • v5.x (current): canonical string-based API, opt-in serde_json, builder-only operator registration. v5 is a hard cliff — no compat shim — so plan a single cutover.
  • v4.x: DataLogic engine, serde_json::Value-first API. Still functional but no longer the active line.
  • v3.x: Arena-based allocation, predates the v4 simplification. Bug-fix only.

If you’re upgrading from v4, see the Migration Guide.

Other languages

The Rust crate is the engine; every other language uses its own binding. Click through to the binding’s guide for install instructions and the language-idiomatic API:

LanguagePackageInstallDeep-dive
Node.js (native, napi-rs)@goplasmatic/datalogic-nodenpm i @goplasmatic/datalogic-nodeNode native README
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:datalogicMaven Central dependencyJava / Kotlin docs
.NETGoplasmatic.Datalogicdotnet add package Goplasmatic.Datalogic.NET docs
PHPgoplasmatic/datalogiccomposer require goplasmatic/datalogicPHP 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 v5 uses Rust edition 2024 — Rust 1.85 or later is required. The crate is built with #![forbid(unsafe_code)].

Verifying Installation

Create a simple script or test file to verify everything works:

// 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.0
// 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

Quick Start

This guide will get you evaluating JSONLogic rules in minutes.

The simplest path: one-shot helpers

For one-off evaluations with no custom operators or custom configurations, you can evaluate rules directly without manually initializing an engine.

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 delegate to a lazily-constructed default engine under the hood (in Java, C#, and PHP, where there is no module-level helper, a default Engine plus apply is the same one-shot). They are the right starting point for tutorials, scripts, and code that doesn’t need custom operators or non-default configurations.

When you need an Engine

Construct an Engine when you need any of: custom operators, custom configurations, templating mode, or a long-lived Session to recycle memory in hot loops.

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 prevent memory growth
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;

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

Engine configuration, sessions, and the full Rust API ladder are covered in the Rust chapter and each language’s chapter.

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

Evaluation failures are structured values, not opaque strings. A failing rule produces an error object with a stable type, and the engine also reports the offending operator and a path breadcrumb to the failing node:

{ "+": ["text", 1] }
// Data: {}
// Error: { "type": "NaN" } (arithmetic on a non-numeric string)

To catch a runtime error inside the rule itself, wrap it in try (Rust crate: enable the error-handling feature; every language binding ships with it enabled):

{ "try": [{ "/": [10, { "var": "divisor" }] }, 0] }
// Data: { "divisor": 0 }
// Result: 0 (the division throws, so the fallback is returned)

Try it:

How uncaught errors surface in your host language (Rust Result, JavaScript exceptions, Python exceptions, Go error values, Java/C#/PHP exceptions) is covered in each binding’s chapter: Node.js, browser WASM, Python, Go, Java, .NET, PHP.

Next Steps

Basic Concepts

Understanding how datalogic-rs works will help you use it effectively.

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 separates rule processing into two distinct phases for maximum execution speed.

Compilation Phase

When you compile a rule, the engine parses the JSON rule, resolves string operator names to integer OpCodes, performs strength reduction and constant folding, and produces a reusable, immutable compiled logic AST:

// 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]}');

Evaluation Phase

During evaluation, the engine dispatches operations via OpCodes and walks the data context. The actual evaluation buffers are allocated within a transient or session-scoped memory arena.

Here is how you evaluate a compiled rule against data using 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 is the central component that holds custom configurations and registered operators. Once constructed, the engine is frozen and immutable.

Here is how to construct and configure an engine across runtimes:

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);
  }
});
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 := datalogic.NewEngineBuilder().
    AddOperator("double", func(argsJson string) (string, error) {
        // implementation
        return "result", nil
    }).
    Build()
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 (frozen at build())
  • Holds the evaluation configuration
  • Provides compile and evaluate methods

Note: v5 makes operator registration builder-only. You can no longer mutate an Engine to add operators after construction.

Context Stack

The context stack manages variable scope during evaluation. This is important for array operations like map, filter, and reduce.

// 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]"

During array operations:

  • "" (or var with empty string) refers to the current element
  • The outer data context is still accessible
  • Nested operations push and pop frames automatically

Type Coercion

JSONLogic operators often perform type coercion:

Arithmetic

Comparison

  • == performs loose equality (with type coercion)
  • === performs strict equality (no coercion)

Truthiness

By default, uses JavaScript-style truthiness:

  • Falsy: false, 0, "", null, []
  • Truthy: everything else

This is configurable via EvaluationConfig.

Thread Safety

Logic is Send + Sync and can be shared across threads via 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());
}

Next Steps

Starter Boilerplates

Ready-to-run microservice integration templates for major frameworks. These patterns demonstrate clean route protection, dynamic calculations, and in-memory feature-flag evaluations using datalogic.


🟢 Node.js + Express (node-express-rules)

Protect routes dynamically using @goplasmatic/datalogic-node middleware. This pattern compiles your rule sets and matches incoming request properties (path, headers, user roles) against them.

Middleware Implementation

import express from 'express';
import { Engine } from '@goplasmatic/datalogic-node';

const app = express();
const engine = new Engine();

// Example authorization rules stored in database
const rules = {
  "/admin": { "==": [{ "var": "user.role" }, "admin"] },
  "/billing": { "in": [{ "var": "user.role" }, ["admin", "billing_manager"]] }
};

// Compile rules for O(1) matching speed
const compiledRules = {};
for (const [route, rule] of Object.entries(rules)) {
  compiledRules[route] = engine.compile(JSON.stringify(rule));
}

// Authorization middleware
const authorize = (req, res, next) => {
  const routeRule = compiledRules[req.path];
  if (!routeRule) return next(); // No rules defined for this route

  // Mock user session context
  const context = {
    user: {
      role: req.headers['x-user-role'] || 'guest'
    }
  };

  try {
    const isAllowed = JSON.parse(routeRule.evaluate(JSON.stringify(context)));
    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 (python-fastapi-pricing)

Perform fast calculations for dynamic discounts, sales tax, or shipping fees at the API boundary using datalogic-py.

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 (rust-axum-feature-flags)

A high-performance feature-flag evaluator that uses transient session recycling to achieve sub-microsecond latency.

Cargo note: session.eval_into::<T, _>(...) is gated behind the serde_json feature. Add it in Cargo.toml: datalogic-rs = { version = "5.1", 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)]
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> {
    // 1. Create a session for thread-local arena allocation
    let mut session = state.engine.session();
    
    // 2. Evaluate
    let result = session.eval_into::<bool, _>(
        &state.rule,
        &serde_json::to_value(payload).unwrap()
    ).unwrap_or(false);
    
    // 3. Reset the arena buffer for reuse on the next request
    session.reset();

    Json(FlagResponse { enabled: result })
}

#[tokio::main]
async fn main() {
    let engine = Engine::new();
    // Rule: Enable beta feature if user is beta_user OR resides 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();
}

Operators Overview

datalogic-rs provides 59 built-in operators organized into logical categories. In the Rust crate, 33 baseline operators are always available in the default build (default = []); a further 24 canonical operators are enabled by opt-in Cargo features, 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 the full set is available out of the box outside Rust. 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. This section documents each operator with syntax, examples, and notes on behavior.

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, sliceArray operations
DateTimedatetime, timestamp, parse_date, format_date, date_diff, nowDate and time
Missing Valuesmissing, missing_someCheck for missing data
Error Handlingtry, throwException handling
flagd-Compatfractional, sem_verFeature-flag targeting (OpenFeature flagd spec); requires features = ["flagd"]

Which operators need which Cargo feature

This split only affects the Rust crate: only the baseline set compiles in the default build (default = []), and using any other operator against an engine compiled without its feature errors at compile time as InvalidOperator. Every language binding enables all operator features, so the full set is always available there.

Cargo featureOperators
baseline (always on)val/var, comparison (==<=), and, or, !, !!, if/?:, + - * / %, min, max, cat, substr, in, map, filter, reduce, merge, all, some, none, missing, missing_some
ext-stringlength, starts_with, ends_with, upper, lower, trim, split
ext-arraysort, slice
ext-mathabs, ceil, floor
ext-controlexists, ??, switch/match, type
error-handlingtry, throw
datetimedatetime, timestamp, parse_date, format_date, date_diff, now
flagdfractional, sem_ver

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

Several operators use lazy (short-circuit) evaluation:

  • and: Stops at first falsy value
  • or: Stops at first truthy value
  • if: Only evaluates the matching branch
  • ?:: Only evaluates the matching branch
  • ??: Only evaluates fallback if first value is null

This is important when operations have side effects or when you want to avoid errors:

{
  "and": [
    { "var": "user" },
    { "var": "user.profile.name" }
  ]
}

If user is null, the second condition is never evaluated, avoiding an error.

Type Coercion

Operators handle types differently:

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. By default (JavaScript-style):

  • Falsy: false, 0, "", null, [], {}
  • Truthy: Everything else

Custom Operators

You can add your own operators. See Custom Operators for details.

In v5 operator registration is builder-only:

let engine = Engine::builder()
    .add_operator("myop", MyOperator)
    .build();

Custom operators follow the same syntax in rules:

{ "myop": [arg1, arg2] }

Note: v5 removed the preserve operator. Wrap literals in templating mode (Engine::builder().with_templating(true).build(), requires feature = "templating") if you need to emit a JSON object verbatim from a rule. Literal scalars and arrays already work inline.

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. 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 (string)
  • default - Optional default value if path doesn’t 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 indices work for both arrays and string characters
  • Returns null if path doesn’t exist and no default is provided

val

Alternative variable access with additional path navigation capabilities.

Syntax:

{ "val": "path" }
{ "val": ["path", default] }

Arguments:

  • path - Path to the value, supports additional navigation syntax
  • default - Optional default value

Returns: The value at the path, or the default value, or null.

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)

Try it:

Notes:

  • val does NOT support var’s dot-path strings: a string argument is a single literal 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
  • Useful for complex data navigation where path segments are computed

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 path segments 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:

  • Returns false for paths that don’t exist
  • Does not check if the value is null/empty, only if the path exists
  • Useful for conditional logic based on data structure

Comparison Operators

Operators for comparing values. All comparison operators support lazy evaluation.

== (Equals)

Loose equality comparison with type coercion.

Syntax:

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

Arguments:

  • a - First value
  • b - Second value

Returns: true if values are equal (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

Try it:

Notes:

  • Performs type coercion similar to JavaScript’s ==
  • For strict comparison without coercion, use ===

=== (Strict Equals)

Strict equality comparison without type coercion.

Syntax:

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

Arguments:

  • a - First value
  • b - Second value

Returns: true if values are equal and same type, 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

Try it:


!= (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.

Examples:

{ "!=": [1, 2] }
// Result: true

{ "!=": [1, "1"] }
// Result: false (type coercion makes them equal)

{ "!=": ["hello", "world"] }
// Result: true

Try it:


!== (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.

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 third value for chained comparison

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 third value for chained comparison

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 third value for chained comparison

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 third value for chained comparison

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:

  • Chained comparisons are useful for range checks
  • { "<": [a, x, b] } is equivalent to a < x AND x < b

Logical Operators

Boolean logic operators with short-circuit evaluation.

! (Not)

Logical NOT - negates a boolean 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 (empty array is falsy)

{ "!": [1, 2] }
// Note: This negates the array [1, 2], not [value]
// Result: false (non-empty array is truthy)

Try it:

Notes:

  • Uses configurable truthiness rules (default: JavaScript-style)
  • Falsy values: false, 0, "", null, []
  • Truthy values: everything else

!! (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

{ "!!": [1, 2, 3] }
// Result: true

{ "!!": [] }
// Result: false

{ "!!": null }
// Result: false

Try it:

Notes:

  • Equivalent to { "!": { "!": value } }
  • Useful for ensuring a boolean result from any value

and

Logical AND with short-circuit evaluation.

Syntax:

{ "and": [a, b, ...] }

Arguments:

  • a, b, … - Two or more values to AND together

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 first falsy value
  • Returns the actual value, not necessarily a boolean
  • Empty and returns null

or

Logical OR with short-circuit evaluation.

Syntax:

{ "or": [a, b, ...] }

Arguments:

  • a, b, … - Two or more values to OR together

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 first truthy value
  • Returns the actual value, not necessarily a boolean
  • Useful for default value patterns
  • Empty or returns null

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

This can be customized via EvaluationConfig. See Configuration.

Arithmetic Operators

Mathematical operations with type coercion support.

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. See the feature table.

+ (Add)

Add numbers together, or concatenate strings.

Syntax:

{ "+": [a, b, ...] }
{ "+": value }

Arguments:

  • a, b, … - Values to add (variadic)
  • Single value is cast to number

Returns: Sum of all arguments, or concatenated string.

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)

// 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

Try it:

Notes:

  • Strings are converted to numbers when possible
  • Non-numeric strings may result in NaN or error (configurable)
  • Single argument converts value to number

- (Subtract)

Subtract numbers.

Syntax:

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

Arguments:

  • a - Value to subtract from
  • b - Value to subtract
  • Single value negates it

Returns: Difference, or negated value.

Examples:

// Subtraction
{ "-": [10, 3] }
// Result: 7

// Unary minus (negate)
{ "-": 5 }
// Result: -5

{ "-": -3 }
// Result: 3

// With coercion
{ "-": ["10", "3"] }
// Result: 7

// Calculate discount
{ "-": [{ "var": "price" }, { "var": "discount" }] }
// Data: { "price": 100, "discount": 15 }
// Result: 85

Try it:


* (Multiply)

Multiply numbers.

Syntax:

{ "*": [a, b, ...] }

Arguments:

  • a, b, … - Values to multiply (variadic)

Returns: Product of all arguments.

Examples:

// Basic multiplication
{ "*": [3, 4] }
// Result: 12

// Multiple values
{ "*": [2, 3, 4] }
// Result: 24

// With coercion
{ "*": ["5", 2] }
// Result: 10

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

Arguments:

  • a - Dividend
  • b - Divisor

Returns: Quotient.

Examples:

// Basic division
{ "/": [10, 2] }
// Result: 5

// Decimal result
{ "/": [7, 2] }
// Result: 3.5

// Division by zero with two integer operands throws an error (error type "NaN")
{ "/": [10, 0] }
// Result: error

// With coercion
{ "/": ["100", "4"] }
// Result: 25

// Calculate average
{ "/": [{ "+": [10, 20, 30] }, 3] }
// Result: 20

Try it:

Notes:

  • Two integer operands with a zero divisor always throw an error (error type “NaN”), regardless of config
  • Only a float zero-divisor honors EvaluationConfig. The default is DivisionByZeroHandling::ReturnSaturated, which returns f64::MAX (or f64::MIN for a negative dividend), not Infinity
  • Other modes (ReturnInfinity, ReturnNull, ThrowError) are selectable via EvaluationConfig

% (Modulo)

Calculate remainder of division.

Syntax:

{ "%": [a, b] }

Arguments:

  • a - Dividend
  • b - Divisor

Returns: Remainder after division.

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:


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:


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:


abs

Get the absolute value.

Syntax:

{ "abs": value }

Arguments:

  • value - Number to get absolute value of

Returns: Absolute (positive) value.

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

Try it:


ceil

Round up to the nearest integer.

Syntax:

{ "ceil": value }

Arguments:

  • value - Number to round up

Returns: Smallest integer greater than or equal to value.

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)

Try it:


floor

Round down to the nearest integer.

Syntax:

{ "floor": value }

Arguments:

  • value - Number to round down

Returns: Largest integer less than or equal to value.

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

Try it:

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.

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

?: (Ternary)

Ternary conditional operator (shorthand if/then/else).

Syntax:

{ "?:": [condition, then_value, else_value] }

Arguments:

  • condition - Condition to evaluate
  • then_value - Value if condition is truthy
  • else_value - Value if condition is falsy

Returns: then_value if condition is truthy, else_value otherwise.

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)

Try it:

Notes:

  • Equivalent to { "if": [condition, then_value, else_value] }
  • More concise for simple conditions
  • 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.

Experimental / known issue: in the current build this operator does not match cases correctly; every input falls through to the default. The syntax and behavior below describe the intended design. See the note at the end of this section.

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 (intended behavior):

{ "switch": [
    { "var": "color" },
    [["red", "stop"], ["green", "go"]],
    "unknown"
]}
// Data: { "color": "green" }
// Intended result: "go"

// Alias `match`
{ "match": [
    { "var": "status" },
    [[200, "OK"], [404, "Not Found"]],
    "Unknown"
]}
// Data: { "status": 404 }
// Intended result: "Not Found"

Notes:

  • Case comparison is strict (no type coercion): the number 1 does not match the string "1".
  • The discriminant is evaluated once and compared against each case in order.
  • Only the matching case’s result (or the default) is evaluated.
  • Known issue: this operator is currently broken in this build, falling through to the default for every input. Avoid relying on it until it is fixed.

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", or "duration".

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"

Notes:

  • type reads exactly one argument. A literal array such as { "type": [1, 2, 3] } is parsed as a multi-argument call, so it inspects the first element (here, "number"). Pass a single value that resolves to an array, e.g. { "type": { "var": "items" } }.
  • Datetime and duration values (from now, datetime, timestamp) report "datetime" / "duration", even though they render as strings in JSON output.

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.

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.

cat

Concatenate strings together.

Syntax:

{ "cat": [a, b, ...] }

Arguments:

  • a, b, … - Values to concatenate (variadic)

Returns: Concatenated string.

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)

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:


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. A literal array such as { "length": [1, 2, 3] } is parsed as a multi-argument call and throws Invalid Arguments. Pass a single value that resolves to an array (for example { "length": { "var": "items" } }).

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"

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

Try it:


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

// The split result is an array you can index into.
{ "split": ["user@example.com", "@"] }
// Result: ["user", "example.com"]
// To select a specific element, index into that array in a later step (for
// example bind the result in your data, or use it inside an array operator).
// The snippet { "var": "0" } is illustrative of selecting the first element
// ("user") from the split result; it is not a standalone rule on its own.

Try it:

Array Operators

Operations for working with arrays, including iteration and transformation.

Feature flags (Rust crate). All array operators are baseline except sort and slice, which require the ext-array feature. Every language binding enables all operator features. See the feature table.

merge

Merge multiple arrays into one.

Syntax:

{ "merge": [array1, array2, ...] }

Arguments:

  • array1, array2, … - Arrays to merge

Returns: Single flattened array.

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]

Try it:


filter

Filter array elements based on a condition.

Syntax:

{ "filter": [array, condition] }

Arguments:

  • array - Array to filter
  • condition - Condition applied to each element (use {"var": ""} for current element)

Returns: Array of elements where condition is truthy.

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:

  • Inside the condition, {"var": ""} refers to the current element
  • The original array is not modified

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:


reduce

Reduce an array to a single value.

Syntax:

{ "reduce": [array, reducer, initial] }

Arguments:

  • array - Array to reduce
  • reducer - Operation combining accumulator and current element
  • initial - Initial value for accumulator

Returns: Final accumulated value.

Context Variables:

  • {"var": "current"} - Current element
  • {"var": "accumulator"} - Current accumulated value

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)

Try it:


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:


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, not a comparator: 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), not an a/b binary comparator. There is no a/b comparator form.

slice

Extract a portion of an array.

Syntax:

{ "slice": [array, start] }
{ "slice": [array, start, end] }

Arguments:

  • array - Source array
  • start - Starting index (negative counts from end)
  • end - Ending index, exclusive (optional, negative counts from end)

Returns: Array slice.

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

Try it:

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). Every language binding enables it. See the feature table.

now

Get the current UTC datetime.

Syntax:

{ "now": [] }

Arguments: None

Returns: The current UTC datetime as a datetime value (rendered as an ISO 8601 string in JSON output).

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:

  • Produces a datetime value, rendered as an ISO 8601 string (e.g., “2024-01-15T14:30:00Z”); its type is “datetime”, not “string”
  • Always uses UTC time
  • Useful for time-based conditions and comparisons

datetime

Parse or validate a datetime value.

Syntax:

{ "datetime": value }

Arguments:

  • value - ISO 8601 datetime string

Returns: A datetime value (rendered as an ISO 8601 string, preserving timezone information); its type is “datetime”, not “string”.

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” or partial like “1d”, “2h”, “30m”, “45s”

Returns: Normalized duration string in format “Xd:Xh:Xm:Xs”.

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

Try it:

Duration Arithmetic

Durations can be used in arithmetic operations:

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

parse_date

Parse a date string with a custom format into an ISO datetime.

Syntax:

{ "parse_date": [string, format] }

Arguments:

  • string - Date string to parse
  • format - Format string using simplified tokens

Returns: Parsed datetime as ISO 8601 string.

Format Tokens:

TokenDescriptionExample
yyyy4-digit year2024
MM2-digit month01-12
dd2-digit day01-31
HH2-digit hour (24h)00-23
mm2-digit minute00-59
ss2-digit second00-59

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"

// With variable
{ "parse_date": [{ "var": "dateStr" }, "yyyy-MM-dd"] }
// Data: { "dateStr": "2024-06-15" }
// Result: "2024-06-15T00:00:00Z"

Try it:


format_date

Format a datetime as a string with a custom format.

Syntax:

{ "format_date": [datetime, format] }

Arguments:

  • datetime - Datetime value to format
  • format - Format string using simplified tokens (same as parse_date)

Returns: Formatted date string.

Special Format:

  • z - Returns timezone offset (e.g., “+0500”)

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
{ "format_date": [{ "datetime": "2024-01-01T10:00:00+05:00" }, "z"] }
// Result: "+0500"

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

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”

Returns: Difference as an integer in the specified unit.

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

// 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": [] }] }

Missing Value Operators

Operators for checking if data fields are missing or undefined.

missing

Check for missing fields in the data.

Syntax:

{ "missing": [key1, key2, ...] }
{ "missing": key }

Arguments:

  • key1, key2, … - Field names to check

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

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

Check if at least N fields are missing from a set.

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)

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 requiredComplex logic needed{ "!": { "missing_some": [N, [...]] } }
Check which are missingReturns missing listReturns missing list if < N present
No minimumAppropriateUse with minimum=1

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

Error Handling Operators

Operators for throwing and catching errors, providing exception-like error handling in JSONLogic.

Feature flag (Rust crate). try and throw require the error-handling feature. Every language binding enables it. See the feature table.

try

Catch errors and provide fallback values.

Syntax:

{ "try": [expression, fallback] }
{ "try": [expression, catch_expression] }

Arguments:

  • expression - Expression that might throw an error
  • fallback - Value or expression to use if an error occurs

Returns: Result of expression if successful, or fallback value/expression result if an error occurs.

Context in Catch: When an error is caught, the catch expression evaluates with the thrown error object as its context, so its fields are read via var / val:

  • A string throw produces the error object { "type": <string> }, so the message is read 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 directly.
  • { "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

// Nested try for multiple error sources
{ "try": [
    { "try": [
        { "var": "data.nested.value" },
        { "throw": "nested access failed" }
    ]},
    "default"
]}

Common Patterns

Safe division:

{ "try": [
    { "/": [{ "var": "numerator" }, { "var": "denominator" }] },
    0
]}

Safe property access:

{ "try": [
    { "var": "user.profile.settings.theme" },
    "default-theme"
]}

Error logging pattern:

{ "try": [
    { "risky_operation": [] },
    { "cat": ["Operation failed: ", { "var": "type" }] }
]}
// 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 it is parsed as an operator map.

Returns: Never returns normally; throws an error that must be caught by try.

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

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

{ "try": [
    { "var": "user.preferences.language" },
    { "try": [
        { "var": "defaults.language" },
        "en"
    ]}
]}
// Try user preference, then defaults, then hardcoded "en"

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

Error Recovery with Retry Logic

{ "try": [
    { "primary_operation": [] },
    { "try": [
        { "fallback_operation": [] },
        "all operations failed"
    ]}
]}

Collecting All Errors

While JSONLogic doesn’t natively support collecting multiple errors, you can structure validations to report all issues:

{
    "errors": { "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] }
    ]}
}

This returns an array of error messages for all validation failures.

flagd-Compat Operators

Two operators specified by the OpenFeature flagd in-process provider for feature-flag targeting. Implemented to match the canonical Go evaluator byte-for-byte, so a flag definition that works under any flagd provider will produce identical variants here.

Cargo feature: flagd. Off by default — opt in via:

datalogic-rs = { version = "5", features = ["flagd"] }

Both operators return null on malformed input (wrong arg count, unparseable version, missing targeting context, etc.) 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.

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. Identical to the Go evaluator’s core/pkg/evaluator/fractional.go. The hash is vendored inline (~30 LOC) for portability across every target.

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 — users aren’t always in the same cohort across your whole product.

2. Implicit bucketing key

Omit the first argument (or pass anything that doesn’t evaluate to a string). 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]
  ]
}

The evaluation data needs to carry both pieces. flagd in-process providers stamp them onto the context as:

{
  "targetingKey": "alice@example.com",
  "$flagd": { "flagKey": "header-color" }
}

Missing or empty 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][50, 50][99, 1] without renormalizing.

Omitted weights default to 1, so ["red"], ["blue"] is equivalent to ["red", 1], ["blue", 1]. Negative weights clamp to 0.

Composing with if

Real-world usage typically gates fractional behind a precondition rather than running it unconditionally:

{
  "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 input1 (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:

Every release runs the full suite, so any flagd-spec drift gets caught before publish.

Rust (Native Crate)

datalogic-rs is the core: everything the other bindings expose is implemented here. Using the crate directly gives you the full API ladder, including the zero-copy and tracing tiers 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:

[dependencies]
datalogic-rs = { version = "5", features = ["serde_json", "datetime", "templating", "trace", "flagd"] }

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");

Module-level helpers (eval, eval_str, eval_into, compile) are backed by a default engine, so one-off evaluation needs no setup.

Five tiers, one engine

The crate exposes a fine-grained API ladder; pick the tier matching your performance budget and trace requirements:

TierAPI Entry PointWhen to use
Tier 0eval_str, eval, eval_into, compileQuick scripts, simple tasks, 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–2 exist in every language binding; Tiers 3 and 4 are 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). Sessions are cheap but not Sync; open one per thread. See Thread Safety for Tokio and rayon patterns.

Where everything else is documented

API Reference

Core types and methods in datalogic-rs v5.

Public surface at a glance

v5 exposes five evaluation tiers, in order of caller control. Pick by use case, not by curiosity — 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_intoper-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::*session-owned + trace bufferTracedRun<R>Debugging, visualisation, instrumentation

The same tier model is exposed in every binding — 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>). Escalate to a real 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.

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"
    .add_operator("my_op", MyOperator)
    .with_constant_folding(true)     // default; pass false to keep every operator visible in the compiled tree
    .build();

v5 makes operator registration builder-only. The Engine produced by build() has a frozen operator set.

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>>;

R: IntoLogic accepts &str (JSON-parsed), &String, &OwnedDataValue / OwnedDataValue, and &serde_json::Value (gated on feature = "serde_json"). Use compile_arc for the dominant cross-thread sharing pattern (equivalent to Arc::new(engine.compile(rule)?)).

eval / eval_str / eval_into (one-shot)

Engine-owned arena per call. The differences are 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>;

#[cfg(feature = "serde_json")]
pub fn eval_into<T, R, D>(&self, rule: R, data: D) -> Result<T>;

R: IntoLogic and D: OwnedInputdata accepts &str, String, &OwnedDataValue / OwnedDataValue, and &serde_json::Value (gated on serde_json). For eval_into, T: DeserializeOwned; the typical choices are serde_json::Value (JSON-shaped boundary) or your own domain struct.

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, &OwnedDataValue, or &serde_json::Value (under feature = "serde_json").

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));

session

Open a Session that owns a reusable arena.

pub fn session(&self) -> Session<'_>;

trace (feature = “trace”)

Open a TracedSession that records execution steps. Mirrors session() 1:1 — every eval* returns a TracedRun<R> carrying the result, steps, and compile-time expression tree.

#[cfg(feature = "trace")]
pub fn trace(&self) -> TracedSession<'_>;

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>

EngineBuilder

Fluent constructor for Engine. Returned by Engine::builder().

EngineBuilder::new()
    .with_config(EvaluationConfig::default())
    .with_templating(true)                  // feature = "templating"
    .with_constant_folding(true)            // default; disable to keep every operator visible
    .add_operator("name", MyOp)             // typed operator
    .add_operator("dyn", boxed_op)          // also accepts Box<dyn CustomOperator>
    .build();

with_constant_folding(false) is useful for tooling that walks the compiled tree and would be surprised by {"+": [1, 2]} collapsing to a literal 3. The trace surface always disables folding internally regardless of this setting.


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.
  • resolve_node_ids(&self, ids: &[u32]) -> Vec<PathStep> — translate the breadcrumb of a structured Error into the source path of the failing node.

Session

Reusable evaluation handle that owns a bumpalo::Bump. The session never auto-resets — the caller decides 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)?;

#[cfg(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();

Session::eval / eval_str / eval_into accept any EvalInput<'_>. eval_borrowed returns a &'a DataValue<'a> that borrows from the session’s arena — Rust’s borrow checker enforces that the next &mut self call invalidates it.


EvalInput

Sealed input adapter trait used by Engine::evaluate, Session::eval_borrowed, and the OwnedInput cousin used by the owned entry points.

ImplementorCost
&'a DataValue<'a>Pass-through.
DataValue<'a>One arena alloc.
&'a strJSON parse via DataValue::from_str.
&OwnedDataValueDeep-borrow into the arena.
&serde_json::Value (feature = "serde_json")Deep-convert into the arena.

The trait is sealed — external crates cannot add new shapes.


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

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 it cannot be built 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
    // more fields may be added in 5.x
}

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);

Presets:

EvaluationConfig::default();
EvaluationConfig::safe_arithmetic();
EvaluationConfig::strict();

NanHandling

pub enum NanHandling {
    ThrowError,    // default
    IgnoreValue,
    CoerceToZero,
    ReturnNull,
}

DivisionByZeroHandling

pub enum DivisionByZeroHandling {
    ReturnSaturated,    // default — f64::MAX / MIN
    ThrowError,
    ReturnNull,
    ReturnInfinity,
}

TruthyEvaluator

pub enum TruthyEvaluator {
    JavaScript,    // default
    Python,
    StrictBoolean,
    Custom(Arc<dyn Fn(&OwnedDataValue) -> bool + Send + Sync>),
}

The Custom callback receives an &OwnedDataValue (not &serde_json::Value).


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

EvalContext

operator::EvalContext<'_, 'a> is an opaque view into the engine’s evaluation context, passed to CustomOperator::evaluate. Most custom operators don’t need to inspect it; the read-only accessors root_input() (the input passed to Engine::evaluate) and depth() (number of iteration frames currently pushed) cover the rare cases where behaviour depends on the surrounding context. The internal stack layout is hidden so it can evolve without breaking the trait contract.


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 operator(&self) -> Option<&str>;  // outermost 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`):
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>),
}

Error serialises (with serde) to:

{
  "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, ...)
}

Use error.tag() for stable string matching, 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.

Error Constructors

Error::invalid_operator(name)
Error::invalid_arguments(msg)
Error::variable_not_found(name)
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)

PathStep

Resolved entry returned by Logic::resolve_node_ids and Error::resolve_path. Names the operator and child index of a node along the failing-evaluation path.


Result Type

pub type Result<T> = std::result::Result<T, Error>;

Trace API (feature = “trace”)

TracedSession

Open via engine.trace(). Mirrors Session 1:1 — every eval* returns a TracedRun<R>.

#[cfg(feature = "trace")]
{
    let engine = datalogic_rs::Engine::new();
    let run = engine.trace().eval_str(r#"{"+": [1, 2]}"#, r#"{}"#);
    println!("{}", run.result.unwrap());
    println!("{} steps", run.steps.len());
}

The pre-compiled paths inherit whatever shape Engine::compile produced (constant folding can hide some operators). For full coverage on a single rule, prefer engine.trace().eval_str(rule, data) — the one-shot path compiles internally with folding disabled.

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 { /* per-node entry / result / error */ }
pub struct ExpressionNode { /* compile-time tree shape with stable ids */ }

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(())
}

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, running close to native Rust throughput.

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. Same core, same semantics, same conformance battery either way.

Install

npm install @goplasmatic/datalogic-node

Prebuilt platform binaries are published 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

Node 18 and newer are supported. There is no build step and no WASM initialization: import and call.

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"

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 is safe to share across worker threads: share one instance and evaluate concurrently.

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.

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. "TypeError", "Thrown")
    console.log(e.operator);   // outermost failing operator
    console.log(e.nodeIds);    // leaf-to-root breadcrumb
    console.log(e.path);       // resolved root-to-leaf step list
  }
}

API surface

SymbolDescription
apply(rule, data)One-shot compile + evaluate
new Engine(config?)Engine with optional configuration and custom operators
engine.compile(rule)Compile to a shareable Rule
rule.evaluate(data)Evaluate against one payload
engine.session()Arena-reusing session for hot loops

Engine configuration, custom operators, and tracing follow the same shapes as every other binding; the package README documents the full surface, and 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 directly and runs at native speed.

The @goplasmatic/datalogic-wasm package provides WebAssembly bindings for the datalogic-rs engine, bringing high-performance JSONLogic evaluation to JavaScript and TypeScript.

Package Installation

# npm
npm install @goplasmatic/datalogic-wasm

# yarn
yarn add @goplasmatic/datalogic-wasm

# pnpm
pnpm add @goplasmatic/datalogic-wasm

Build Targets

The package includes three build targets optimized for different environments:

TargetUse CaseInit Required
webBrowser ES Modules, CDNYes
bundlerWebpack, Vite, RollupYes
nodejsNode.js (CommonJS/ESM)No

Automatic Target Selection

The package’s exports field automatically selects the appropriate target:

// Browser/Bundler - uses web or bundler target
import init, { evaluate } from '@goplasmatic/datalogic-wasm';

// Node.js - uses nodejs target
const { evaluate } = require('@goplasmatic/datalogic-wasm');

Explicit Target Import

If you need a specific target:

// Web target (ES modules with init)
import init, { evaluate } from '@goplasmatic/datalogic-wasm/web';

// Bundler target
import init, { evaluate } from '@goplasmatic/datalogic-wasm/bundler';

// Node.js target
import { evaluate } from '@goplasmatic/datalogic-wasm/nodejs';

WASM Initialization

For browser and bundler environments, you must initialize the WASM module before using any functions:

import init, { evaluate } from '@goplasmatic/datalogic-wasm';

// Initialize once at application startup
await init();

// Now you can use evaluate, CompiledRule, etc.
const result = evaluate('{"==": [1, 1]}', '{}', false);

Note: Node.js does not require initialization - you can use functions immediately after import.

TypeScript Support

The package includes TypeScript declarations. No additional @types package is needed.

import init, { evaluate, CompiledRule, evaluateWithTrace } from '@goplasmatic/datalogic-wasm';

// Full type inference for all exports
const result: string = evaluate('{"==": [1, 1]}', '{}', false);

Bundle Size

The WASM binary is a single self-contained module: roughly 1.6 MB uncompressed, around 400 to 500 KB gzipped, making it suitable for web applications where performance is critical.

CDN Usage

For quick prototyping or simple pages, you can load directly from a CDN:

<script type="module">
  import init, { evaluate } from 'https://unpkg.com/@goplasmatic/datalogic-wasm@latest/web/datalogic_wasm.js';

  async function run() {
    await init();
    console.log(evaluate('{"==": [1, 1]}', '{}', false)); // "true"
  }

  run();
</script>

Next Steps

Quick Start

This guide covers the essential patterns for using JSONLogic in JavaScript/TypeScript.

Basic Evaluation

The simplest way to evaluate JSONLogic:

import init, { evaluate } from '@goplasmatic/datalogic-wasm';

// Initialize WASM (required for browser/bundler)
await init();

// Evaluate a simple expression
const result = evaluate('{"==": [1, 1]}', '{}', false);
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 = evaluate(logic, data, false);
console.log(result); // "25"

// Multiple variables
const priceLogic = '{"*": [{"var": "price"}, {"var": "quantity"}]}';
const orderData = '{"price": 10.99, "quantity": 3}';
console.log(evaluate(priceLogic, orderData, false)); // "32.97"

Compiled Rules

For repeated evaluation of the same logic, use CompiledRule for better performance:

import init, { CompiledRule } from '@goplasmatic/datalogic-wasm';

await init();

// Compile once
const rule = new CompiledRule('{">=": [{"var": "age"}, 18]}', false);

// 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

Results are returned as JSON strings. Parse them for use in your application:

const result = evaluate('{"+": [1, 2, 3]}', '{}', false);
const value = JSON.parse(result); // 6 (number)

// For complex results
const arrayResult = evaluate('{"map": [[1,2,3], {"+": [{"var": ""}, 10]}]}', '{}', false);
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 = new CompiledRule(gradeLogic, false);
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(evaluate(filterLogic, data, false));
// [{ name: "Phone", price: 299 }, { name: "Headphones", price: 50 }]

Templating Mode

Enable templating for JSON templating:

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
});

// Third parameter = true enables templating mode
const result = JSON.parse(evaluate(template, data, true));
// {
//   "user": { "fullName": "Alice Smith", "isAdult": true },
//   "timestamp": "2024-01-15T10:30:00Z"
// }

Error Handling

Wrap evaluations in try-catch:

try {
  const result = evaluate('{"invalid": "json', '{}', false);
} catch (error) {
  console.error('Evaluation failed:', error);
}

Debugging

Use evaluateWithTrace for step-by-step debugging:

import init, { evaluateWithTrace } from '@goplasmatic/datalogic-wasm';

await init();

const trace = evaluateWithTrace(
  '{"and": [{"var": "a"}, {"var": "b"}]}',
  '{"a": true, "b": false}',
  false
);

const traceData = JSON.parse(trace);
console.log('Result:', traceData.result);
console.log('Steps:', traceData.steps);

Next Steps

API Reference

Complete API documentation for the @goplasmatic/datalogic-wasm WebAssembly package.

Functions

init()

Initialize the WebAssembly module. Required before using any other functions in browser/bundler environments.

function init(input?: InitInput): Promise<InitOutput>;

Parameters:

  • input (optional) - Custom WASM source (URL, Response, or BufferSource)

Returns: Promise that resolves when initialization is complete

Example:

import init from '@goplasmatic/datalogic-wasm';

// Standard initialization
await init();

// Custom WASM location
await init('/custom/path/datalogic_wasm_bg.wasm');

Note: Node.js does not require initialization.


evaluate()

Evaluate a JSONLogic expression against data.

function evaluate(logic: string, data: string, templating: boolean): string;

Parameters:

  • logic - JSON string containing the JSONLogic expression
  • data - JSON string containing the data context
  • templating - Enable templating mode (multi-key objects compile to output-shaping templates with embedded JSONLogic)

Returns: JSON string containing the result

Throws: String error message if evaluation fails

Examples:

// Simple comparison
evaluate('{"==": [1, 1]}', '{}', false); // "true"

// Variable access
evaluate('{"var": "name"}', '{"name": "Alice"}', false); // "\"Alice\""

// Arithmetic
evaluate('{"+": [1, 2, 3]}', '{}', false); // "6"

// Array operations
evaluate('{"map": [[1,2,3], {"+": [{"var": ""}, 1]}]}', '{}', false); // "[2,3,4]"

// Templating mode
evaluate(
  '{"result": {"var": "x"}, "computed": {"+": [1, 2]}}',
  '{"x": 42}',
  true
); // '{"result":42,"computed":3}'

evaluateWithTrace()

Evaluate with detailed execution trace for debugging.

function evaluateWithTrace(logic: string, data: string, templating: boolean): string;

Parameters: Same as evaluate()

Returns: JSON string containing a TracedResult:

interface TracedResult {
  result: any;              // Evaluation result
  expression_tree: {        // Tree structure of the expression
    id: number;
    expression: string;
    children: ExpressionNode[];
  };
  steps: Step[];            // Execution steps
}

interface Step {
  step_id: number;
  node_id: number;
  context: any;
  result?: any;   // present on success
  error?: string;  // present on failure
  iteration_index?: number;
  iteration_total?: number;
}

The TracedResult JSON layout is the JavaScript-side wire shape and is stable across the v4 → v5 cutover. On the Rust side it is produced from a datalogic_rs::TracedRun<String> (see the Rust API reference).

Example:

const trace = evaluateWithTrace(
  '{"and": [true, {"var": "x"}]}',
  '{"x": false}',
  false
);

const data = JSON.parse(trace);
console.log(data.result);      // false
console.log(data.steps.length); // 3 (and, true literal, var lookup)

Classes

CompiledRule

Pre-compiled rule for efficient repeated evaluation.

Constructor

new CompiledRule(logic: string, templating: boolean)

Parameters:

  • logic - JSON string containing the JSONLogic expression
  • templating - Enable templating mode

Throws: If the logic is invalid JSON or contains compilation errors

Example:

const rule = new CompiledRule('{">=": [{"var": "age"}, 18]}', false);

Methods

evaluate(data: string): string

Evaluate the compiled rule against data.

evaluate(data: string): string;

Parameters:

  • data - JSON string containing the data context

Returns: JSON string containing the result

Example:

const rule = new CompiledRule('{"+": [{"var": "a"}, {"var": "b"}]}', false);

rule.evaluate('{"a": 1, "b": 2}');  // "3"
rule.evaluate('{"a": 10, "b": 20}'); // "30"

Tracing a compiled rule: the WASM CompiledRule exposes evaluate only. For execution traces, call the standalone evaluateWithTrace(logic, data, templating) function — it recompiles per call but returns the full TracedResult shape.


Type Definitions

Input/Output Types

All functions accept and return JSON strings. Parse results for use:

// Input: Always JSON strings
const logic: string = JSON.stringify({ "==": [1, 1] });
const data: string = JSON.stringify({ x: 42 });

// Output: Always JSON strings
const result: string = evaluate(logic, data, false);
const parsed: boolean = JSON.parse(result); // true

Templating Mode

When templating is true:

  • Unknown object keys become output fields
  • Only recognized operators are evaluated
  • Useful for JSON templating
// Without templating - "result" treated as unknown operator
evaluate('{"result": {"var": "x"}}', '{"x": 1}', false);
// Error or unexpected behavior

// With templating - "result" becomes output field
evaluate('{"result": {"var": "x"}}', '{"x": 1}', true);
// '{"result":1}'

Error Handling

All functions throw string errors on failure:

try {
  evaluate('{"invalid json', '{}', false);
} catch (error) {
  // error is a string describing the problem
  console.error('Failed:', error);
}

Common error types:

  • JSON parse errors (invalid syntax)
  • Unknown operator errors (when templating is off)
  • Type errors (wrong argument types)
  • Variable access errors (missing required data)

Performance Tips

  1. Use CompiledRule for repeated evaluation:

    // Slow: recompiles each time
    for (const user of users) {
      evaluate(logic, JSON.stringify(user), false);
    }
    
    // Fast: compile once
    const rule = new CompiledRule(logic, false);
    for (const user of users) {
      rule.evaluate(JSON.stringify(user));
    }
    
  2. Initialize once at startup:

    // Application entry point
    await init();
    // Now use evaluate/CompiledRule anywhere
    
  3. Reuse CompiledRule instances:

    // Store compiled rules
    const rules = {
      isAdult: new CompiledRule('{">=": [{"var": "age"}, 18]}', false),
      isPremium: new CompiledRule('{"==": [{"var": "tier"}, "premium"]}', false),
    };
    

Framework Integration

This guide covers integration with popular JavaScript frameworks and build tools.

React

Basic Setup

import { useEffect, useState } from 'react';
import init, { evaluate, CompiledRule } from '@goplasmatic/datalogic-wasm';

function App() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    init().then(() => setReady(true));
  }, []);

  if (!ready) return <div>Loading...</div>;

  return <RuleEvaluator />;
}

function RuleEvaluator() {
  const result = evaluate('{"==": [1, 1]}', '{}', false);
  return <div>Result: {result}</div>;
}

Custom Hook

Create a reusable hook for JSONLogic evaluation:

import { useEffect, useState, useMemo } from 'react';
import init, { CompiledRule } from '@goplasmatic/datalogic-wasm';

// Initialize once at module level
let initPromise: Promise<void> | null = null;
function ensureInit() {
  if (!initPromise) {
    initPromise = init();
  }
  return initPromise;
}

export function useJsonLogic(logic: object, data: unknown) {
  const [ready, setReady] = useState(false);
  const [result, setResult] = useState<unknown>(null);
  const [error, setError] = useState<string | null>(null);

  const rule = useMemo(() => {
    if (!ready) return null;
    try {
      return new CompiledRule(JSON.stringify(logic), false);
    } catch (e) {
      setError(String(e));
      return null;
    }
  }, [logic, ready]);

  useEffect(() => {
    ensureInit().then(() => setReady(true));
  }, []);

  useEffect(() => {
    if (!rule) return;
    try {
      const res = rule.evaluate(JSON.stringify(data));
      setResult(JSON.parse(res));
      setError(null);
    } catch (e) {
      setError(String(e));
    }
  }, [rule, data]);

  return { result, error, ready };
}

Usage:

function FeatureFlag({ feature, user }) {
  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 init, { CompiledRule } from '@goplasmatic/datalogic-wasm';

export function useCompiledRule(logic: object) {
  return useQuery({
    queryKey: ['compiled-rule', JSON.stringify(logic)],
    queryFn: async () => {
      await init();
      return new CompiledRule(JSON.stringify(logic), false);
    },
    staleTime: Infinity,
  });
}

Vue

Composition API

<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import init, { CompiledRule } from '@goplasmatic/datalogic-wasm';

const ready = ref(false);
const data = ref({ age: 25 });

onMounted(async () => {
  await init();
  ready.value = true;
});

const rule = computed(() => {
  if (!ready.value) return null;
  return new CompiledRule('{">=": [{"var": "age"}, 18]}', false);
});

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, watchEffect, Ref } from 'vue';
import init, { CompiledRule } from '@goplasmatic/datalogic-wasm';

let initialized = false;
let initPromise: Promise<void> | 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);

  onMounted(async () => {
    if (!initialized) {
      if (!initPromise) initPromise = init();
      await initPromise;
      initialized = true;
    }
    ready.value = true;
  });

  watchEffect(() => {
    if (!ready.value) return;
    try {
      const rule = new CompiledRule(JSON.stringify(logic.value), false);
      result.value = JSON.parse(rule.evaluate(JSON.stringify(data.value)));
      error.value = null;
    } catch (e) {
      error.value = String(e);
    }
  });

  return { result, error, ready };
}

Node.js

Express Middleware

const express = require('express');
const { evaluate, CompiledRule } = require('@goplasmatic/datalogic-wasm');

const app = express();
app.use(express.json());

// Compile rules at startup
const rules = {
  canAccess: new CompiledRule(JSON.stringify({
    "and": [
      { "==": [{ "var": "role" }, "admin"] },
      { "var": "active" }
    ]
  }), false)
};

// 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 { evaluate } = require('@goplasmatic/datalogic-wasm');

app.post('/api/evaluate', (req, res) => {
  const { logic, data, preserveStructure = false } = req.body;

  try {
    const result = evaluate(
      JSON.stringify(logic),
      JSON.stringify(data),
      preserveStructure
    );
    res.json({ result: JSON.parse(result) });
  } catch (error) {
    res.status(400).json({ error: String(error) });
  }
});

Bundler Configuration

Vite

WASM works out of the box with Vite:

// 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, { evaluate } from '@goplasmatic/datalogic-wasm';

export function JsonLogicEvaluator({ logic, data }) {
  const [result, setResult] = useState(null);

  useEffect(() => {
    init().then(() => {
      const res = evaluate(JSON.stringify(logic), JSON.stringify(data), false);
      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, { evaluate } from 'https://unpkg.com/@goplasmatic/datalogic-wasm@latest/web/datalogic_wasm.js';

    async function run() {
      await init();

      const logic = JSON.stringify({ ">=": [{ "var": "age" }, 18] });
      const data = JSON.stringify({ age: 21 });
      const result = JSON.parse(evaluate(logic, data, false));

      document.getElementById('result').textContent =
        result ? 'Adult' : 'Minor';
    }

    run();
  </script>
</body>
</html>

Worker Threads

Web Worker

// worker.js
import init, { CompiledRule } from '@goplasmatic/datalogic-wasm';

let rule = null;

self.onmessage = async (e) => {
  if (e.data.type === 'init') {
    await init();
    rule = new CompiledRule(e.data.logic, false);
    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 { CompiledRule } = require('@goplasmatic/datalogic-wasm');

if (isMainThread) {
  const worker = new Worker(__filename);
  worker.postMessage({ logic: '{"==": [1, 1]}', data: {} });
  worker.on('message', (result) => console.log(result));
} else {
  parentPort.on('message', ({ logic, data }) => {
    const rule = new CompiledRule(JSON.stringify(logic), false);
    const result = JSON.parse(rule.evaluate(JSON.stringify(data)));
    parentPort.postMessage(result);
  });
}

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 is compiled using pyo3 against the PEP 384 Stable ABI (abi3). This means:

  • The same prebuilt wheel works across multiple minor Python versions (3.10, 3.11, 3.12, 3.13, etc.).
  • No local C compilation or Rust installation is needed when installing the wheel.

Importing in Python

Note the module naming convention:

  • 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

Quick Start

Evaluate rules instantly in Python using the datalogic-py binding.

Simple One-Shot Evaluation

Use apply for simple, 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. This eliminates parsing overhead and parses the rule directly into the optimized Rust bytecode:

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"

Parsing Performance: evaluate vs evaluate_str

  • rule.evaluate(dict_data) accepts a Python dict or list and converts it directly into Rust types using pythonize. This is 3–10× faster than a standard JSON-string 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 bypass Python-to-Rust dictionary marshaling completely.

Next: Configuration & Errors covers engine configuration presets, the exception hierarchy, and type conversion.

API & GIL Management

datalogic-py provides context managers for arena recycling and releases the Global Interpreter Lock (GIL) to enable true parallelism.

Session Lifecycle (Context Manager)

For tight loops, use the session() context manager. It manages a reusable memory arena and automatically resets it between iterations.

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

Python’s multi-threading is typically limited by the Global Interpreter Lock (GIL). However, datalogic-py releases the GIL during the evaluation phase.

  • Parallel execution: If you run rule.evaluate inside a ThreadPoolExecutor or standard Python threading.Thread, multiple evaluations will run concurrently on separate CPU cores inside the Rust engine.
  • Best Practice: Share a single Engine and compiled Rule across all threads. Keep Session objects thread-local (one per thread).
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}]

# Evaluates concurrently across OS threads, bypassing Python's GIL
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(rule.evaluate, users))

print(results) # [True, False, True, False]

Error Handling

All runtime exceptions in the Python binding inherit from DataLogicError. There are two main subclasses:

  • ParseError: Raised when rules or input datasets are malformed, or if an unsupported Python type (e.g. set or tuple) is provided.
  • EvaluateError: Raised during evaluation. Exposes .error_type, .operator, .node_ids (a list of compiled-node ids forming a leaf-to-root breadcrumb), and .path (a list of step dicts, each with node_id, operator, arg_index, and json_pointer).
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}")   # a real runtime tag
    print(f"Failed at: {e.operator}") # "+"
    print(f"Path: {e.path}")          # list of step dicts

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, so typos fail loudly instead of being silently ignored.

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

The presets: "default" is JSONLogic-compatible behavior; "safe_arithmetic" skips non-numeric operands and returns None on division by zero; "strict" errors on any type mismatch and disables numeric coercion.

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, 0]}, {})        # None (the override wins)

try:
    engine.eval({"+": ["1", 2]}, {})  # strict does not coerce "1" to a number
except EvaluateError as e:
    print(e.error_type)

A JSON string works anywhere the dict does: Engine(config='{"preset": "safe_arithmetic"}'). Every binding shares this JSON schema and parses it with the same core code, so a config that works here works in the WASM, Node, and Go bindings too. Full semantics of each knob, with behavior tables, are in Configuration.

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

EvaluateError carries structured attributes:

  • .error_type: the engine’s stable error tag, e.g. "Thrown", "TypeError", "InvalidArguments", "InvalidOperator".
  • .operator: the outermost 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.

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 thrown payload is serialized into the exception message as Thrown: <payload JSON>.

Type Conversion

The dict-input path (apply, Engine.eval, Rule.evaluate) converts Python values with pythonize:

Supported: dict, list, str, int, float, bool, None.

Not supported, these raise ParseError with a clear message:

  • datetime.datetime, datetime.date: convert to an ISO string at the Python edge
  • decimal.Decimal: convert to float or str
  • bytes, set, tuple
  • float('nan'), float('inf'): the JSON spec disallows them

For payloads with exotic types, use rule.evaluate_str(json_text) and bring your own JSON encoder (e.g. json.dumps(payload, default=str)).

Installation & cgo Setup

The Go binding datalogic-go bridges Go and the underlying Rust core statically using cgo.

Go Module Path

To add the Go module dependency (note the /v5 major-version suffix required by Go Modules for versions 2 and above):

go get github.com/GoPlasmatic/datalogic-rs/bindings/go/v5@latest

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 automatically select the correct static library (libdatalogic_c.a) at build time.

Requirements

  • Compilation: You only need a standard C compiler (e.g. gcc or clang / Xcode command line tools) to link the static library during go build.
  • No Rust Required: You do not need the Rust toolchain installed on the machine building the Go application; the compiled Rust engine is already packaged inside the static library.

Quick Start

Evaluate rules instantly in Go using 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. This parses the rule a single time into a reusable, optimized compiled form (an arena-allocated node tree), so repeated evaluations skip re-parsing.

Important: Always defer .Close() on engines and rules to prevent C FFI memory leaks!

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

Next: Configuration & Errors covers engine configuration via the builder and the *datalogic.Error type.

Concurrency & Sessions

Go’s goroutines make concurrency central. datalogic-go maps directly to Rust’s thread-safety properties.

Concurrency Model

  • Engine: Thread-safe (Send + Sync in Rust). Construct a single Engine and share it across goroutines safely.
  • Rule: Thread-safe. Compile a rule once, and call rule.Evaluate() from multiple goroutines concurrently.
  • Session: Not thread-safe. Sessions manage a reusable memory arena for evaluation buffers. Share them only within a single goroutine or task, never concurrently.

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

Error Handling

Errors are returned as *datalogic.Error structs, which carry detailed debugging metadata:

  • Type: The error class name (e.g. ParseError, Thrown, TypeError).
  • Operator: The outermost operator where the execution failed.
  • PathJSON: A JSON-array string describing the path from the rule root to the failing node, where elements carry fields like operator and json_pointer.
_, 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("AST Path: %s\n", dErr.PathJSON)
    }
}

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.AddOperator(name, fn): register a custom operator.
  • b.Build(): consume the builder and return the configured *Engine.

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", so typos fail loudly. 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
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

The presets: "default" is JSONLogic-compatible behavior; "safe_arithmetic" skips non-numeric operands and returns null on division by zero; "strict" errors on any type mismatch and disables numeric coercion.

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, not silently
}
engine, err := b.Build()
if err != nil {
    log.Fatal(err)
}
defer engine.Close()

out, _ := engine.Apply(`{"/": [1, 0]}`, `{}`)    // "null" (the override wins)
_, err = engine.Apply(`{"+": [null, 1]}`, `{}`)  // err != nil: strict rejects non-numeric operands

Builders are not goroutine-safe: construct and Build() on one goroutine, then share the resulting Engine freely (see Concurrency & Sessions). 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
OperatorOutermost 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

Error() formats as datalogic: <Type>: <Message>. The stable tags: ParseError, Thrown, TypeError, InvalidArguments, InvalidOperator, VariableNotFound, ArithmeticError, Custom, FormatError, IndexOutOfBounds, InvalidContextLevel, ConfigurationError. Arithmetic NaN failures and the rule-level throw operator both surface as "Thrown", with the thrown payload serialized into Message.

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) // [{"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.

Java / Kotlin (JVM)

The JVM binding io.github.goplasmatic:datalogic reaches the shared C ABI directly 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.1.0</version>
</dependency>

Gradle (build.gradle)

implementation 'io.github.goplasmatic:datalogic:5.1.0'

Note: 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

Always compile rules when executing them repeatedly. Use Java’s try-with-resources statement to ensure native resources are disposed of correctly:

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 internal arena; does not allocate fresh memory
                    String name = session.evaluate(rule, input);
                    System.out.println(name);
                }
            }
        }
    }
}

Concurrency

  • Engine and Rule instances are fully thread-safe and can be shared globally.
  • Session instances are not thread-safe and must be kept local to individual threads.

Going deeper

.NET / C# (P/Invoke)

The .NET binding Goplasmatic.Datalogic is a P/Invoke wrapper over the shared C ABI. It targets .NET 8.0 and uses source-generated LibraryImport stubs, making it fully NativeAOT-ready.

Installation

Add the NuGet package to your project:

dotnet add package Goplasmatic.Datalogic

The package ships precompiled shared libraries (.so, .dylib, .dll) under NuGet’s standard runtimes/ structure. MSBuild picks the correct target runtime identifier (RID) during publish.

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

Always compile rules when executing them 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 memory arena; does not allocate fresh memory
    var name = session.Evaluate(rule, input);
    Console.WriteLine(name);
}

Concurrency

  • Engine and Rule instances are thread-safe and can be shared globally.
  • Session instances are not thread-safe and must be kept local to individual threads.
  • All public types implement IDisposable. If a developer forgets to call Dispose(), the wrappers contain finalizers to release native memory as a best-effort fallback. However, explicit disposal is highly recommended to prevent resource starvation.

Going deeper

PHP (FFI)

The PHP binding goplasmatic/datalogic uses PHP’s native FFI extension (ext-ffi) to interact with the shared C ABI. It requires PHP 8.4 or newer.

Installation

Add the Composer dependency to your project:

composer require goplasmatic/datalogic

Ensure that PHP’s FFI extension is enabled in your php.ini configuration:

extension=ffi
# For command line tools and web servers, allow FFI
ffi.enable=true

The Composer package ships with precompiled shared libraries under lib/<os>-<arch>/. The loader automatically detects and loads the library for the current platform.

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

Always compile rules when executing them repeatedly. This parses the rule into optimized bytecode 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"

// Explicitly close resources to free native handles early
$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 internal memory arena
    $name = $session->evaluate($rule, $input);
    echo $name, "\n";
}

$session->close();
$rule->close();
$engine->close();

Memory Management

In PHP, FFI-allocated memory is released when wrapper objects go out of scope and are collected by the PHP engine. However, in long-running environments (such as PHP-FPM, Swoole, RoadRunner, or CLI daemons), garbage collection delays can accumulate heap usage.

To guarantee immediate cleanup, call $object->close() explicitly on the Engine, Rule, or Session wrappers.

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, build instructions, and cbindgen notes live in the C ABI README.

Binary distribution

Because these bindings rely on compiled shared/static libraries, the release pipeline compiles the bindings/c code for all supported operating systems and architectures. The binaries are then bundled into the standard package layout for each ecosystem.

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::cdef at runtime

The JSON-in/JSON-out rule

To keep the C ABI surface simple and performant, inputs and outputs crossing the boundary are UTF-8 JSON strings 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). No complex struct marshaling is performed at the boundary. Instead, inputs are serialized to JSON in the host language, passed to Rust, evaluated, and the result is returned as JSON bytes to be parsed back by the host.

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 significantly from native Go/Java/C#/PHP code.

⚠️ The danger: native memory leaks

When you instantiate an Engine or compile a Rule in a managed language, the actual structures (optimized bytecode ASTs, configuration options, operator collections) are allocated on the native Rust heap, and only a raw 64-bit memory pointer is returned 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 (which is usually a few bytes representing the pointer address). The GC has no awareness of the potentially megabytes of memory allocated on the native heap behind that pointer.

If you let these wrapper objects go out of scope without calling their destructors, the native memory will leak permanently until the host process terminates.

🛡️ Best practices per language

Follow these patterns to ensure leak-free evaluation:

🟢 Go: explicit cleanup with defer

Go does not support object finalizers or automatic destructors for local variables. You must call .Close() explicitly.

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. All datalogic classes implement AutoCloseable, making this the cleanest and safest pattern:

// 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 provides a finalizer fallback, but explicit disposal is highly recommended:

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. However, for CLI daemons, Swoole services, or long-running PHP-FPM requests, always 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();

🧵 Thread safety & concurrency

When sharing compiled logic across multiple threads, remember the following thread-safety boundaries:

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.

Why Session is not thread-safe

Session contains a fast, zero-copy bumpalo arena allocator. It works by moving a cursor forward on a pre-allocated memory page. If two threads evaluate logic concurrently using the same session, they will overwrite each other’s memory, leading to crashes or data corruption.

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:

PackageVersionPurpose
react18+ or 19+React framework
react-dom18+ or 19+React DOM renderer
@xyflow/react12+Flow diagram rendering

Note: The @goplasmatic/datalogic-wasm WASM package is bundled internally for evaluation.

CSS Setup

Import the required styles in your application entry point or component:

// React Flow base styles (required)
import '@xyflow/react/dist/style.css';

// DataLogicEditor styles (required)
import '@goplasmatic/datalogic-ui/styles.css';

Style Import Order

Import order matters. Always import React Flow styles before DataLogicEditor styles:

// Correct order
import '@xyflow/react/dist/style.css';
import '@goplasmatic/datalogic-ui/styles.css';

// Then import components
import { DataLogicEditor } from '@goplasmatic/datalogic-ui';

Minimal Example

import '@xyflow/react/dist/style.css';
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 requires a container with defined dimensions:

// 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

Types are included in the package. Import types as needed:

import type {
  DataLogicEditorProps,
  JsonLogicValue,
} from '@goplasmatic/datalogic-ui';

Bundler Notes

Vite

Works out of the box. No additional configuration needed.

Webpack

Ensure CSS loaders are configured:

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
};

Next.js

For App Router, use client components:

'use client';

import '@xyflow/react/dist/style.css';
import '@goplasmatic/datalogic-ui/styles.css';

import { DataLogicEditor } from '@goplasmatic/datalogic-ui';

export function LogicVisualizer({ expression }) {
  return <DataLogicEditor value={expression} />;
}

Next Steps

Quick Start

This guide covers essential patterns for using the DataLogicEditor component.

Basic Visualization

Render a JSONLogic expression as a flow diagram:

import '@xyflow/react/dist/style.css';
import '@goplasmatic/datalogic-ui/styles.css';

import { DataLogicEditor } from '@goplasmatic/datalogic-ui';

function App() {
  const expression = {
    "and": [
      { ">": [{ "var": "age" }, 18] },
      { "==": [{ "var": "status" }, "active"] }
    ]
  };

  return (
    <div style={{ width: '100%', height: '500px' }}>
      <DataLogicEditor value={expression} />
    </div>
  );
}

Debugging

Add evaluation results by providing a data context. When data is present, the editor exposes debugger controls with a step-through 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>
  );
}

With data provided, each node displays its evaluated result, making it easy to trace how the final value was computed.

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';

function EditableExample() {
  const [expression, setExpression] = useState({
    ">": [{ "var": "cart.total" }, 100]
  });

  return (
    <div style={{ width: '100%', height: '600px' }}>
      <DataLogicEditor
        value={expression}
        onChange={setExpression}
        editable
      />
    </div>
  );
}

Add data to combine editing with live debugging in the same view.

Theme Support

The editor supports 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, so a data-theme on a parent or ancestor is not read. Use the theme prop to force a theme.

Handling Null/Empty Expressions

The editor gracefully handles null or undefined expressions:

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. Its behavior is driven entirely by which props you pass. 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
DebuggerdataDiagram with per-node evaluation results and a step-through traceYes
EditingeditableVisual builder: node selection, properties panel, context menus, undo/redoNo
TemplatingtemplatingMulti-key objects and arrays become output-shaping templatesNo

These are not mutually exclusive. 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
  • Color-coded operator categories

Debugging

Provide a data prop and the editor overlays evaluation results on each node, showing how the expression evaluates against the data, and exposes debugger controls for stepping through the execution trace.

<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:
  • Evaluation results displayed on each node
  • Step-by-step execution visibility via debugger controls
  • Context values shown for variable nodes
  • Highlighted execution path

Internally, when data is provided the component uses the WASM evaluateWithTrace API to capture the result of each sub-expression, the order of evaluation, context values at each step, and the final computed result.

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
  • Context menus (right-click a node or the canvas)
  • Undo/redo

When editable is set, onChange is active: edits are debounced (about 300ms) and the rebuilt JSONLogic expression is passed back so you can keep your own state in sync.

Editing with Live Debugging

Combine editable with data to edit and debug in the same view: each node shows its evaluated result while you build the expression.

<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)). The toolbar also surfaces a templating checkbox; wire onTemplatingChange to keep your state in sync.

<DataLogicEditor
  value={expression}
  templating={templating}
  onTemplatingChange={setTemplating}
/>

Behavior Comparison

AspectRead-onlyDebugger (data)Editing (editable)
Node displayStructure onlyStructure + valuesEditable nodes
InteractivityPan/zoomPan/zoom + inspectionFull editing
data requiredNoYesNo
OutputStaticStatic + traceTwo-way bound via onChange

Performance Considerations

  • Read-only is fastest: no evaluation overhead.
  • Debugger runs evaluation on every data change.
  • Editing rebuilds the expression on each change (debounced before onChange fires).

For large expressions or frequent data updates, consider debouncing 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 ? 'Hide results' : 'Show results'}
      </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.

// 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 debugger controls become available and each node shows its evaluated result via the WASM trace API.

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: edits in the canvas are debounced (about 300ms) and the rebuilt JSONLogic expression is passed back.

onChange?: (expr: JsonLogicValue | null) => void
<DataLogicEditor
  value={expression}
  onChange={setExpression}
  editable
/>

editable

Enable editing: node selection, properties panel, context menus, and undo/redo.

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.

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. Ignored when the editor is non-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

theme

Theme override.

theme?: 'light' | 'dark'

Default: System preference

<DataLogicEditor value={expr} theme="dark" />

className

Additional CSS class for the container.

className?: string
<DataLogicEditor value={expr} className="my-editor" />

Type Definitions

JsonLogicValue

The type for JSONLogic expressions:

type JsonLogicValue =
  | string
  | number
  | boolean
  | null
  | JsonLogicValue[]
  | { [operator: string]: JsonLogicValue };

DataLogicEditorProps

interface DataLogicEditorProps {
  value: JsonLogicValue | null;
  onChange?: (expr: JsonLogicValue | null) => void;
  data?: unknown;
  theme?: 'light' | 'dark';
  className?: string;
  templating?: boolean;
  onTemplatingChange?: (value: boolean) => void;
  editable?: boolean;
  exampleSuggestions?: string[];
  onSelectExample?: (name: string) => void;
}

LogicNode

A React Flow node carrying our 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'
  | 'datetime'
  | 'validation'
  | 'error'
  | 'utility';

Exports

Component

import { DataLogicEditor } from '@goplasmatic/datalogic-ui';

Types

import type {
  DataLogicEditorProps,
  JsonLogicValue,
  LogicNode,
  LogicEdge,
  LogicNodeData,
  OperatorNodeData,
  VariableNodeData,
  LiteralNodeData,
  NodeEvaluationResult,
  EvaluationResultsMap,
  OperatorCategory,
} from '@goplasmatic/datalogic-ui';

Constants

import { OPERATORS, CATEGORY_COLORS } from '@goplasmatic/datalogic-ui';

OPERATORS: Map of operator names to their metadata (category, label, etc.)

CATEGORY_COLORS: Color definitions for each operator category

Utilities

import { jsonLogicToNodes, applyTreeLayout } from '@goplasmatic/datalogic-ui';

jsonLogicToNodes: Convert JSONLogic expression to React Flow nodes/edges

const { nodes, edges, rootId } = jsonLogicToNodes(expression, { templating });

applyTreeLayout: Apply dagre tree layout to nodes

const layoutedNodes = applyTreeLayout(nodes, edges);

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[]
): LogicNode[]

Parameters:

  • nodes - Array of nodes
  • edges - Optional array of edges. When omitted, edges are derived from the node relationships

Returns: Nodes with updated positions and dimensions. The layout flows left-to-right.


Advanced Usage

Custom Node Rendering

For advanced customization, you can use the utilities to render with your own React Flow setup:

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 custom styling
const logicalColor = CATEGORY_COLORS.logical;  // '#8b5cf6'

Next Steps

Customization

This guide covers theming, styling, and advanced customization of the DataLogicEditor.

Theming

System Theme (Default)

By default, the editor detects 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 does not read data-theme from 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 is applied via .logic-editor[data-theme="dark"].

These are the real variable names defined by the component. The values below are the light-theme defaults:

.logic-editor {
  /* Backgrounds */
  --bg-primary: #fafafa;
  --bg-secondary: #ffffff;
  --bg-tertiary: #f6f6f7;
  --bg-hover: #f0f0f1;
  --bg-active: #e8e8ea;

  /* Text */
  --text-primary: #18181b;
  --text-secondary: #3f3f46;
  --text-tertiary: #71717a;
  --text-muted: #a1a1aa;
  --text-placeholder: #c4c4c7;

  /* Borders */
  --border-primary: rgba(0, 0, 0, 0.10);
  --border-secondary: rgba(0, 0, 0, 0.06);
  --border-light: rgba(0, 0, 0, 0.04);

  /* Accents */
  --accent-blue: #6366f1;
  --accent-blue-light: #e0e7ff;
  --accent-blue-hover: #4f46e5;
  --accent-amber: #f59e0b;
  --accent-amber-light: #fef3c7;

  /* Nodes */
  --node-bg: #ffffff;
  --node-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
  --node-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.05);
}

Override any of them by re-declaring on the same scope (the dark variant lives on .logic-editor[data-theme="dark"]):

.logic-editor {
  --accent-blue: #3b82f6;
  --node-bg: #ffffff;
}

.logic-editor[data-theme="dark"] {
  --node-bg: #18181b;
  --node-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
}

Node Styling

Target specific node types:

/* All nodes */
.react-flow__node {
  font-family: 'Inter', sans-serif;
}

/* Operator nodes (and, or, if, var, val, ==, +, etc.) */
.react-flow__node-operator {
  border-width: 2px;
}

/* Literal nodes (strings, numbers, booleans, null) */
.react-flow__node-literal {
  font-weight: bold;
}

/* Structure nodes (JSON objects/arrays in templating mode) */
.react-flow__node-structure {
  font-style: italic;
}

Note: There are three node types: operator, literal, and structure. There is no variable node type, variables (var / val) render as operator nodes, so a .react-flow__node-variable selector matches nothing.

Edge Styling

Customize connection lines:

.react-flow__edge-path {
  stroke: #6b7280;
  stroke-width: 2px;
}

.react-flow__edge.selected .react-flow__edge-path {
  stroke: #3b82f6;
}

Layout Customization

Container Dimensions

The editor requires explicit dimensions:

// 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:

import { ReactFlow, Background, Controls } from '@xyflow/react';
import { jsonLogicToNodes, applyTreeLayout, CATEGORY_COLORS } 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>
  );
}

Custom Node Types

Create custom node components:

import { Handle, Position } from '@xyflow/react';
import { CATEGORY_COLORS } from '@goplasmatic/datalogic-ui';

function CustomOperatorNode({ data }) {
  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>
      {data.result !== undefined && (
        <div style={{ fontSize: '0.75em', opacity: 0.8 }}>
          = {JSON.stringify(data.result)}
        </div>
      )}
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

const customNodeTypes = {
  operator: CustomOperatorNode,
  // ... other custom types
};

Category Colors

Access and customize category colors:

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',
//   control: '#f59e0b',
//   datetime: '#0ea5e9',
//   validation: '#94a3b8',
//   utility: '#64748b',
//   error: '#ef4444',
//   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

Make the editor responsive:

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

Memoize expression objects to prevent unnecessary re-renders:

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

For frequently changing data in debug mode:

import { useDeferredValue } from 'react';

function DebugWithDeferred({ expression, data }) {
  const deferredData = useDeferredValue(data);

  return (
    <DataLogicEditor
      value={expression}
      data={deferredData}
    />
  );
}

Custom Operators

Extend datalogic-rs with your own operators to implement domain-specific logic.

v5 changes: Custom operators receive pre-evaluated &DataValue<'a> arguments and return arena-allocated values. The old “args are unevaluated; call evaluator.evaluate()” model is gone, and so is the Evaluator trait. The trait is named CustomOperator, and registration is builder-only.

The CustomOperator Trait

use bumpalo::Bump;
use datalogic_rs::operator::EvalContext;
use datalogic_rs::{CustomOperator, DataValue, 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>>;
}
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; the read-only observations [EvalContext::root_input] and [EvalContext::depth] cover the rare cases where behaviour depends on the surrounding context.
arenaThe bumpalo::Bump allocator for the current call. Use arena.alloc(...) for DataValues and arena.alloc_str(...) for strings.

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 the engine is built, its operator set is frozen and immutable.

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 := 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()
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"

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",
    }
}

The Error type is structured: tag() returns a stable variant tag, and the operator / path fields are populated automatically by the engine when a custom operator returns an error.

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. Validate argument count and types early.
  2. Allocate results in the arena (arena.alloc(...) / arena.alloc_str(...)).
  3. Return meaningful errorsError::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.

Configuration

Customize evaluation behavior with EvaluationConfig and the 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();

v5 dropped the inherent Engine::with_config / with_preserve_structure / with_config_and_structure constructors — use the builder. There is no compatibility shim. See the Migration Guide for the v4 → v5 mapping.

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

Control how non-numeric values are handled in arithmetic operations.

use datalogic_rs::{EvaluationConfig, NanHandling};

// ThrowError (default), IgnoreValue, CoerceToZero, ReturnNull
let config = EvaluationConfig::default()
    .with_arithmetic_nan_handling(NanHandling::IgnoreValue);

Behavior comparison for {"+": [1, "text", 2]}:

SettingResult
ThrowError (default)Err(Thrown { type: "NaN" })
IgnoreValue3 (skips "text")
CoerceToZero3 ("text"0)
ReturnNullnull

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

SettingResult
ReturnSaturated (default)f64::MAX (sign of dividend)
ThrowErrorErr(Thrown { type: "NaN" })
ReturnNullnull
ReturnInfinityInfinity (sign of dividend)

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));

v5 change: TruthyEvaluator::Custom now takes Arc<dyn Fn(&OwnedDataValue) -> bool + Send + Sync> (the canonical owned value type). v4 used &serde_json::Value.

Truthiness comparison:

ValueJavaScriptPythonStrictBoolean
truetruthytruthytruthy
falsefalsyfalsyfalsy
1truthytruthyfalsy
0falsyfalsyfalsy
""falsyfalsyfalsy
"0"truthytruthyfalsy
[]falsyfalsyfalsy
[0]truthytruthyfalsy
nullfalsyfalsyfalsy

Loose Equality Errors

Control 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),
    );

Max Recursion Depth

Cap the number of nested evaluation-boundary calls before the engine bails with a ConfigurationError. The limit is tracked per thread and guards against custom operators that hold an Arc<Engine> and re-enter via engine.evaluate(...). Pure built-in workloads skip the check entirely, so they pay nothing.

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);

Configuration Presets

use datalogic_rs::{Engine, EvaluationConfig};

// Lenient arithmetic — IgnoreValue + ReturnNull divide-by-zero
let engine = Engine::builder()
    .with_config(EvaluationConfig::safe_arithmetic())
    .build();

// Strict — errors for any type mismatch 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. This is the wire format the language bindings use to pass engine configuration across FFI boundaries through one shared parser; Rust callers normally use the typed with_* setters above.

All keys are optional. The "preset" key is applied first, then the remaining keys override individual fields on top of it. Unknown keys and unknown enum strings are rejected with a ConfigurationError, so typos fail loudly instead of being silently 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

Custom truthiness closures (TruthyEvaluator::Custom) cannot be expressed in JSON; they are available through the Rust API only.

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:

{
  "preset": "safe_arithmetic",
  "truthy_evaluator": "strict_boolean",
  "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();

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, "2"]}"#, r#"{}"#);
// Err(...) — strict mode does not coerce "2" to a number

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"

Structured Objects (Templating)

Use JSONLogic as a templating engine with templating mode.

Requires feature = "templating". The mode is off by default.

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();

How It Works

In normal mode, unknown keys in a JSON object are treated as errors (or as custom operators when one is registered). With structure preservation enabled, unknown keys become literal output fields.

Normal mode:

{ "user": { "var": "name" } }
// Error: "user" is not a known operator

Structure preservation mode:

{ "user": { "var": "name" } }
// Result: { "user": "Alice" }

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

Arrays are processed 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

In v4 there was an explicit preserve operator that wrapped a value to prevent further evaluation. v5 removed it. Wrap-as-output is exactly what templating mode already does for objects, and literal scalars / arrays already pass through inline. If you need to emit a JSON object verbatim from a rule, enable with_templating(true) and write the object directly.

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

You can mix operators and structure freely:

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"}
        }
    ]}
}"#;

Thread Safety

datalogic-rs is designed for thread-safe, concurrent evaluation.

Thread-Safe Design

Logic is Send + Sync

Logic (the v5 name for CompiledLogic) is Send + Sync. v5 does not auto-wrap it in Arc — wrap it yourself when you want cheap cross-thread sharing, or use 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 — just bumps the refcount.
let compiled_clone = Arc::clone(&compiled);

Engine itself 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());
}

Async Runtime Integration

With Tokio

Evaluation is CPU-bound — use spawn_blocking to keep async runtimes responsive:

use datalogic_rs::{Engine, Logic};
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);
    }
}

Thread Pool Pattern

For high-throughput scenarios, use a thread pool — each worker keeps its own Session so the arena is reused 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 auto-reset. 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

Use when you genuinely need thread-local engine state:

thread_local! {
    static ENGINE: datalogic_rs::Engine = datalogic_rs::Engine::new();
}

ENGINE.with(|engine| {
    let compiled = engine.compile(r#"{"==": [1, 1]}"#).unwrap();
    let mut session = engine.session();
    session.eval_str(&compiled, r#"{}"#)
});

Custom Operator Thread Safety

CustomOperator is 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 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();

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 — recompiles every iteration
for data in datasets {
    let compiled = engine.compile(rule).unwrap();
    engine.eval_str(rule, data)?;
}

Reuse the Arena

Session reuses one bumpalo::Bump across calls; the caller calls 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 directly.

Short-Circuit Evaluation

and, or, if, ?:, and ?? short-circuit. Order conditions so that the cheapest / most-likely-to-decide ones come 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()),
    }
}

Security and Sandboxing

datalogic-rs is designed to evaluate 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 exactly 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 is built with #![forbid(unsafe_code)]. The language bindings necessarily 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 fully reproducible evaluation, either avoid now or inject the current time as input data instead. All 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.
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 nested Engine::evaluate re-entry from custom operators that hold an Arc<Engine>. Configurable via EvaluationConfig::with_max_recursion_depth. Pure built-in workloads skip the check.

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.

What is NOT bounded

The engine does not impose limits on, and has no built-in timeout or cancellation for:

  • Wall-clock time / CPU. A rule that iterates a large array or nests map/reduce/filter can run for a long time.
  • Iteration count. map/filter/reduce/all/some/none process every element of whatever array they are given.
  • Output size. A templating rule or merge can produce an output much larger than its input.

These are all functions of the input data size and the rule complexity, both of which you control. Mitigate them at the edges:

  1. Bound attacker-controlled input. Cap array lengths and total payload size before evaluating. This is the single most effective control, because iteration and output size scale with the data, not the rule.
  2. Bound rule complexity. For user-authored rules, cap the serialized rule size and reject or lower max_recursion_depth / compile depth as appropriate for your risk tolerance.
  3. 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, input and complexity bounds are sufficient and far cheaper; reach for process isolation only when you must survive an adversarial worst case.

Untrusted-rule checklist

  • Compile rules once and reject the ones that fail to compile (InvalidOperator, malformed JSON, over-depth) before they reach a hot path.
  • Size-limit the input data (array lengths, total bytes).
  • Size-limit the rule text.
  • Decide how throw should surface: a thrown error is a normal Result::Err (kind 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.
  • Avoid now (or inject time 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.

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 (still microseconds, but don’t 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';

const engine = new Engine();          // one per process; thread-safe
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 rules
    cache.set(key, rule);
  }
  return rule;
}

Compiled Rule objects are immutable and safe to share, 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.

The endpoint

// app.js
import express from 'express';
import { 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 manual 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 {
    compiled = engine.compile(req.body.logic); // syntax + operator check
  } catch (err) {
    return res.status(422).json({ error: `invalid rule: ${err.message}` });
  }
  // run the rule against golden cases before activating it
  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' });
    }
  }
  // ...persist row with a bumped version...
  res.sendStatus(204);
});

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 compile-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

Evaluations are typically sub-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, the exact rule your Express service enforces can be previewed in the browser with @goplasmatic/datalogic-wasm, or rendered and step-debugged with the React visual editor: no re-implementation, no drift between what the UI shows and what the API decides.

Error handling

compile and evaluate throw real Error objects with a stable errorType tag ("ParseError", "TypeMismatch", "Thrown", …). Map them in your Express error middleware:

app.use((err, req, res, next) => {
  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, tracing).

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 type 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.1.0</version>
</dependency>

Native libraries are bundled in the jar per platform. JDK 22+ is required; run with native access enabled:

--enable-native-access=ALL-UNNAMED

(In application.properties-land this usually means adding it to your launch script or JAVA_TOOL_OPTIONS; Boot itself needs nothing else.)

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()));
    }
}

Compiled rules are shared safely across request threads. If rule churn is high, evict old versions (Caffeine or a bounded LinkedHashMap) and close() evicted rules to release their native handles promptly.

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 composes naturally with 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, and run golden tests before activating.

@PostMapping("/rules")
public ResponseEntity<?> saveRule(@RequestBody @Size(max = 65_536) String logic) {
    try (Rule candidate = engine.compile(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) {
        return ResponseEntity.unprocessableEntity().body(e.getMessage());
    }
    // 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.

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 (evaluateBoolean-style) and batch entry points to skip JSON result parsing. Patterns and the full tier table are in the JVM chapter.

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. One engine, no drift.

Use Cases & Examples

Real-world JSONLogic recipes for common scenarios. Every rule on this page is plain JSON: author 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 ships bindings for. Each recipe below is the rule, a sample data payload, and the result; standard-mode recipes also embed a live widget so you can run them right here. A few recipes use the engine’s templating mode to build output objects; those are flagged inline.

Run any of these in your language

The pattern is identical everywhere: compile the rule once, then evaluate it against as many data payloads as you like.

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 a hash of the user ID:

{
    "<": [
        { "%": [{ "var": "user.id" }, 100] },
        20
    ]
}

Data:

{"user": {"id": 12345}}

Result: false (12345 % 100 = 45, and 45 is not below the 20 cutoff)

Try it:

Beta Access

Enable for beta testers OR employees OR users who signed up before a date. The ends_with operator requires the ext-string feature in Rust; enabled by default in every binding.

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

Calculate prices based on rules.

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 with complex rules.

Required Fields

Report which required fields are absent; the missing operator inside the template evaluates to exactly 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 like the valid/errors branch need templating mode: in Rust, the templating Cargo feature plus Engine::builder().with_templating(true); in every binding, the templating flag when constructing the engine. 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 requires the ext-string feature in Rust; enabled by default in every binding.

{
    "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. Needs the engine’s templating mode (templating feature + Engine::builder().with_templating(true) in Rust, the templating constructor flag in every binding); 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 literal output fields, which needs templating mode (see Required Fields above); 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

Is the required permission 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), unusual hour (+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" }, 23] }
            ]},
            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 require the ext-string feature in Rust; enabled by default in every binding.

{
    "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. Needs the engine’s templating mode (templating feature + Engine::builder().with_templating(true) in Rust, the templating constructor flag in every binding); run it in the playground with Templating switched on.

Report Generation

Build a report object with a computed title, a generation timestamp, and reduced summary stats. format_date and now require the datetime feature and length the ext-string feature in Rust; both enabled by default in every binding.

{
    "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. Needs the engine’s templating mode (templating feature + Engine::builder().with_templating(true) in Rust, the templating constructor flag in every binding); 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 templates, which needs templating mode (templating feature + Engine::builder().with_templating(true) in Rust, the templating constructor flag in every binding); 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 not inside their quiet hours:

{
    "and": [
        { "var": "user.notifications_enabled" },
        { "in": [
            { "var": "notification.type" },
            { "var": "user.enabled_types" }
        ]},
        { "!": { "and": [
            { ">=": [{ "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

Try it:


Where next

Performance

This guide covers performance optimization, benchmarking, and best practices for datalogic-rs.

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

Built-in operators use direct OpCode dispatch instead of string lookups:

  • 59 built-in operators have direct dispatch
  • Custom operators use a single map lookup
  • No runtime reflection or dynamic dispatch

Memory Efficiency

v5 optimizations:

  • 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 arenasSession reuses one Bump across calls; the caller calls 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 incur no per-call allocation.
  • Arc<Logic> — cheap clone for cross-thread sharing.

Benchmarking

Running Benchmarks

The benchmark harness lives in its own dev-only crate, datalogic-bench, under tools/benchmark/. Two binaries share a common harness:

# 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).

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 absolute hot path, drop down to Engine::evaluate and manage the arena yourself — the result is a zero-copy &DataValue<'a> and avoids the deep-clone Session does at the boundary.

use bumpalo::Bump;

let arena = Bump::new();
let result = engine.evaluate(&compiled, r#"{"x": 1}"#, &arena).unwrap();
// `result` is `&DataValue<'_>` — borrows 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 — recompiles every iteration
for data in datasets {
    let compiled = engine.compile(rule).unwrap();
    engine.eval_str(rule, data)?;
    let _ = compiled;
}

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)
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. Order conditions so the cheapest / most-likely-to-decide check comes 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 actually need to mutate.

let n = args[0].as_f64().unwrap_or(0.0); // cheap read

5. Minimize Nested Variable Access

Deep paths require multiple lookups:

{"var": "user.profile.settings.theme.color"}   // slow
{"var": "themeColor"}                           // fast

JavaScript / WASM Performance

CompiledRule Advantage

const iterations = 10_000;

console.time('evaluate');
for (let i = 0; i < iterations; i++) {
  evaluate(logic, data, false);
}
console.timeEnd('evaluate');

const rule = new CompiledRule(logic, false);
console.time('compiled');
for (let i = 0; i < iterations; i++) {
  rule.evaluate(data);
}
console.timeEnd('compiled');

Typical improvement: 2–5× faster with CompiledRule.

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 every executed node.

#[cfg(feature = "trace")]
{
    let run = engine.trace().eval_str(rule, data);
    for step in &run.steps {
        // step.node_id, step.expression, step.context, step.result, ...
    }
}

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() }

How It Compares

datalogic-rs is a JSONLogic engine: rules are JSON data, evaluated by one Rust core that is wrapped for eight languages plus the browser. This page positions it against the alternatives people most often evaluate it beside. 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 playgroundPlay pageNone officialNone officialJDM editor + commercial BRMSCommunity playgrounds
ExtensibilityCustom operators per host languageCustom ops in JSCustom ops (incl. async) in JSLimitedCustom nodesExtension functions per environment

One naming collision deserves a call-out: jsonlogic-rs on crates.io is bestowinc/json-logic-rs, a different project from this one despite the near-identical name. This crate is datalogic-rs. The two are compared directly below.

One engine vs. N ports

JSONLogic’s core promise is portability: rules are plain JSON, so any language can evaluate them. The ecosystem’s structural problem is how that promise gets delivered. json-logic-js is the JavaScript reference, and every other language depends on an independent reimplementation: separate community ports for Python, PHP, Go, Ruby, Java, and more, each with its own maintainer, release cadence, and bug tail. 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 quietly evaluate differently in your Python batch job.

datalogic-rs inverts the model: one Rust core, compiled 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; none of them reimplements a single operator. Semantic parity across languages is a build artifact, not a hope, and two concrete checks keep it that way:

  • The same 1,565-case conformance battery (54 suites) runs against the core in CI. Every binding ships the exact engine those cases validated, so there is no per-language test matrix to fall behind.
  • The flagd fractional operator is byte-compatible with the canonical Go evaluator’s MurmurHash3 bucketing, so even hash-based percentage rollouts put 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. Its two most distinctive properties are one engine shared binary-identically across eight languages, and an official visual debugger for the standard. Each alternative below is genuinely the better pick in its own lane.

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 value the smallest and most battle-tested dependency, and the reference engine’s performance is comfortable at your rule volume. As the standard’s source of truth, it is the canonical 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

The fast, actively maintained JavaScript engine, and the credible JS-side alternative on speed: its compiled mode is the only non-Rust subject in 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 a compiled-core engine cannot offer as naturally.

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 identically named neighbor 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 reasonable, 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 31x 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 executes no rule-supplied code at all.

CEL

Common Expression Language owns the “safe expression language” space in the Kubernetes and Envoy ecosystems, with a strong 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

One honest caveat: the WASM build under Node measures 900.5 ns (88x native), so on Node servers the native binding is the fast path; 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)evaluate(rule, data, false)
Pythonapply(rule, data)
Godatalogic.Apply(rule, data)
Javaengine.apply(rule, data)
C#engine.Apply(rule, data)
PHP$engine->apply($rule, $data)

Not eight lookalike engines: the same compiled core behind eight call signatures. Try the rule live in the playground, then pick your language in Installation.

Migration Guide

This page is a quick conceptual overview. The full v4 → v5 cookbook — every renamed call, every cargo-feature swap, every error-handling update — lives in MIGRATION.md at the repo root. Treat that file as authoritative.

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 — there is no transitional crate configuration. Plan a single cutover: update Cargo.toml, run a find-and-replace pass, and re-run your test suite.

The on-the-wire JSONLogic spec is unchanged — your rules and data still look the same. Everything that changes is on the Rust side.

What changed at a glance

  • Type renames. DataLogicEngine, CompiledLogicLogic, OperatorCustomOperator, ArenaValueDataValue, ArenaContextStackoperator::EvalContext. Evaluator is gone (args arrive pre-evaluated).
  • Method renames. Every evaluate_* is now eval_*. evaluate_streval_str, evaluate_borrowedeval_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 — no need to construct an Engine for the simple cases.
  • Sessions are explicit. Reusable arenas live on Session (engine.session()); the session never auto-resets, so callers 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(), 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 already pass through inline; templated objects belong in templating mode (rebuild with Engine::builder().with_templating(true).build(), requires 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’re stepping from v3 directly to v5, the v3 → v4 jump is a historical layer that no longer matches anything in this codebase. Read the v4-to-v5 section above and the repo-root MIGRATION.md; everything you need to land on v5 is covered there.

Getting Help

If you encounter issues during migration:

  1. Check the API Reference
  2. Review the examples
  3. 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 (one function per binding) and a few configurable behaviors. This page is the short version; see How It Compares for the positioning.

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 is string-in, string-out.

import init, { evaluate } from '@goplasmatic/datalogic-wasm';
await init();
evaluate('{">": [{"var": "age"}, 18]}', '{"age": 21}', false); // "true"

Same rule, same result. For repeated evaluation of one rule, compile it once (Engine/CompiledRule) instead of calling the one-shot helper 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 (pre-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),
});

See each binding’s “Custom operators” section for the exact shape.

Behavioral differences to know

datalogic-rs’s defaults are slightly stricter than json-logic-js’s, and are configurable. The two you are most likely to notice:

  • Cross-type loose equality. By default datalogic-rs raises on comparisons that json-logic-js would silently resolve to false (for example an object compared to a number). For json-logic-js-classic behavior, build the engine with loose_equality_errors = false.
  • Division by zero. datalogic-rs is configurable (ReturnSaturated by default, or ReturnNull / ThrowError / ReturnInfinity); integer division by zero always errors. Pick the division_by_zero mode that matches your expectations.

Both live on EvaluationConfig; see Configuration.

Extensions you gain

Beyond the JSONLogic baseline, datalogic-rs adds opt-in operators the reference engine does not ship: datetime arithmetic, string helpers (length, starts_with, split, …), sort/slice, 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.

FAQ

Frequently asked questions about datalogic-rs.

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 — significantly faster than JS implementations
  • Thread SafetyLogic is Send + Sync; wrap in Arc to share
  • Extended Operators — datetime, string operations, error handling, more
  • Type Safety — full Rust type system benefits
  • WASM Support — same engine in browsers and Node.js
  • Zero unsafe — the crate is built with #![forbid(unsafe_code)]

Is datalogic-rs fully compatible with JSONLogic?

Yes. datalogic-rs passes the complete official JSONLogic test suite. It also includes additional operators that extend the specification.


Rust Usage

Should I use v4 or v5?

Use v5 for new projects. The API is cleaner, the default build does not pull in serde_json, and the arena evaluation path is exposed directly. See the Migration Guide for the move from v4.

v5 is a hard cliff — there is no compatibility shim, so plan a single cutover when upgrading from v4. The repo-root MIGRATION.md has the per-call cookbook.

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)
});

Why are custom operator arguments pre-evaluated in v5?

The pre-evaluated, arena-based design makes custom operators behave like built-ins: the engine recurses, hands you &DataValue<'a> borrows, and you return another arena allocation. This avoids the boundary conversion that the v4 Operator trait paid on every call and removes the need for a separate Evaluator trait.

If you need lazy / short-circuit semantics like and / or, that lives in built-in operators today (none of the v5 short-circuit operators are exposed through the public custom-operator surface).

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 when you don’t need custom operators or non-default config.
Engine::eval_str (and eval / eval_into)R: IntoLogic, D: OwnedInputString (or OwnedDataValue / T)One-shot through a configured engine. Allocates a fresh arena internally.
Engine::evaluate&Logic, any EvalInput, &Bump&'a DataValue<'a>Hot path. Caller owns the arena, result borrows from it.
Session::eval_str (and eval / eval_into)&Logic, D: EvalInputString (or OwnedDataValue / T)Reuses the session’s arena across calls. Caller calls 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. The Node.js target does not require initialization:

const { evaluate } = require('@goplasmatic/datalogic-wasm');
evaluate('{"==": [1, 1]}', '{}', false);

Why do I need to JSON.stringify my data?

The WASM interface uses string-based communication for maximum compatibility:

const result = evaluate(
  JSON.stringify(logic),
  JSON.stringify(data),
  false
);
const value = JSON.parse(result);

How do I use this with TypeScript?

Types are included in the package:

import init, { evaluate, CompiledRule } from '@goplasmatic/datalogic-wasm';

await init();
const result: string = evaluate('{"==": [1, 1]}', '{}', false);

React UI

Why does the editor need explicit dimensions?

React Flow (the underlying library) requires a container with defined dimensions to calculate node positions and viewport.

<div style={{ height: '500px' }}>
  <DataLogicEditor value={expression} />
</div>

Can I use this with Next.js?

Yes. For the App Router, wrap in a client component:

'use client';

import '@xyflow/react/dist/style.css';
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?

Use the missing or missing_some operators:

{"if": [
    {"missing": ["user.email"]},
    "Email required",
    "Valid"
]}

Or use default values with var:

{"var": ["user.email", "no-email@example.com"]}

What happened to the preserve operator?

It was removed in v5. Literal scalars and arrays already pass through inline, and templated objects belong in templating mode (Engine::builder().with_templating(true).build(), requires feature = "templating").


Configuration

How do I handle NaN in arithmetic?

Use the NanHandling 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, IgnoreValue, ReturnNull.

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), ThrowError, ReturnNull, ReturnInfinity.


Troubleshooting

“Invalid operator” error

In standard mode, unrecognized keys are treated as errors. Either:

  1. Fix the operator name (operators are case-sensitive)
  2. Register a custom operator on the builder
  3. Enable templating mode (feature = "templating") — Engine::builder().with_templating(true).build()

Performance issues with large expressions

  1. Use Session for repeated calls (arena reuse)
  2. Drop to Engine::evaluate with a caller-managed bumpalo::Bump for the absolute hot path
  3. Profile with feature = "trace" to identify slow sub-expressions

WASM initialization fails

Ensure you await init() before calling other functions:

await init();
const result = evaluate(...);

For more troubleshooting, see the Troubleshooting Guide.

Troubleshooting

Common issues and solutions for datalogic-rs.

Rust Issues

“Invalid operator: xyz”

Cause: Using an unrecognized operator name.

Solutions:

  1. Check the operator name spelling (operators are case-sensitive).
  2. Register a custom operator on the builder.
  3. Enable 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")
#[cfg(feature = "templating")]
let engine = datalogic_rs::Engine::builder().with_templating(true).build();

“Variable not found”

Cause: Accessing a path that doesn’t exist in the data.

Solutions:

  1. Check the variable path spelling
  2. Use a default value
  3. Use missing to check first
{"var": ["user.name", "Anonymous"]}

{"if": [
    {"missing": ["user.name"]},
    "No name",
    {"var": "user.name"}
]}

Unexpected NaN / Thrown errors from arithmetic

Cause: Non-numeric values in arithmetic operations.

Solution: Configure NaN handling:

use datalogic_rs::{Engine, EvaluationConfig, NanHandling};

let config = EvaluationConfig::default()
    .with_arithmetic_nan_handling(NanHandling::IgnoreValue); // or CoerceToZero
let engine = Engine::builder().with_config(config).build();

“the trait bound T: CustomOperator is not satisfied” / Send-Sync errors

Cause: Custom operator type that isn’t 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 (DataLogicEngine, CompiledLogicLogic, OperatorCustomOperator, 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(&rule, data)engine.eval_into::<Value, _, _>(&rule, &data) (requires feature = "serde_json")
    • engine.evaluate_json_with_trace(rule, data)engine.trace().eval_str(rule, data) returning TracedRun<String>

Slow compilation

Cause: Very large or deeply nested expressions.

Solutions:

  • Compile once, evaluate many times
  • Break expressions into smaller composable pieces
  • Profile with feature = "trace" to see which sub-expressions dominate
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

“RuntimeError: memory access out of bounds”

Cause: WASM module not initialized.

Solution: Call init() before using any functions:

import init, { evaluate } from '@goplasmatic/datalogic-wasm';

await init();
evaluate(logic, data, false);

“TypeError: Cannot read properties of undefined”

Cause: Wrong import style for your environment.

Solutions:

// Browser/Bundler — need default import for init
import init, { evaluate } from '@goplasmatic/datalogic-wasm';

// Node.js — no init needed
const { evaluate } = require('@goplasmatic/datalogic-wasm');

“Failed to fetch” in browser

Cause: WASM file not accessible from the browser.

Solutions:

  1. Check your bundler configuration
  2. Ensure WASM files are served correctly
  3. Check CORS headers if loading from CDN

For Webpack:

// webpack.config.js
module.exports = {
  experiments: {
    asyncWebAssembly: true,
  },
};

Results are strings, not values

Cause: WASM returns JSON strings, not native values.

Solution: Parse the result:

const resultString = evaluate(logic, data, false);
const result = JSON.parse(resultString);

Performance issues

Cause: Recompiling rules repeatedly.

Solution: Use CompiledRule:

const rule = new CompiledRule(logic, false);
for (const item of items) {
  rule.evaluate(JSON.stringify(item));
}

React UI Issues

“ResizeObserver loop completed with undelivered notifications”

Cause: Container size changes rapidly. 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 '@xyflow/react/dist/style.css';
import '@goplasmatic/datalogic-ui/styles.css';

Debug mode not showing results

Cause: data prop not provided.

Solution:

<DataLogicEditor
  value={expression}
  data={{ x: 1, y: 2 }}
/>

SSR / Hydration errors in Next.js

Cause: WASM doesn’t run on server.

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