A Kotlin Multiplatform implementation of JsonLogic — build rules as JSON, share them between front-end and back-end, and evaluate them the same way on every platform Kotlin runs on.
This library is a fork of jamsesso/json-logic-java (MIT, © 2018 Sam Jesso), rewritten as a pure Kotlin Multiplatform module. It follows the Java original closely — before the Java sources were removed from this repository, both engines were run side by side over the same 335-case fixture corpus (289 value cases, 46 error cases) and machine-verified to agree on every one — but where the two disagree, the JsonLogic reference implementation wins. See Known deviations & sharp edges below for where the two parted ways, and for what else to watch for before you rely on this library.
Try it in your browser: crafted.branch.co/json-logic-kmp
| Target | Notes |
|---|---|
jvm |
|
android |
minSdk 21 |
iosArm64 |
|
iosSimulatorArm64 |
|
wasmJs |
Node.js runtime |
That is the whole list. The playground runs in a browser on Kotlin/JS, but it compiles the
library's sources itself rather than resolving a coordinate — there is no js publication, and
adding one is a support commitment a browser demo does not justify.
Values are modeled as kotlinx.serialization.JsonElement on every target. A rule crosses the API
boundary as either a JsonElement or a JSON string, but data is always a JsonElement? — parse a
serialized data string with Json.parseToJsonElement first (see Usage below).
https://crafted.branch.co/json-logic-kmp/ — two JSON editors and a live result panel, with example presets and a reference for every operation. Links are shareable: Share puts the current rule and data in the URL.
Nothing is evaluated on a server. The playground is a Kotlin/JS app in playground/ that builds
the page out of DOM elements, with no UI framework under it. It adds
lib/src/commonMain/kotlin as a source directory of its own, so the browser runs the same engine
sources that ship to every other platform without a js coordinate existing to resolve. It is
deployed from main by .github/workflows/pages.yml and is not part of any published artifact.
To run it locally, with live reload:
./gradlew :playground:jsBrowserDevelopmentRunTo build the deployable bundle, into playground/build/dist/js/productionExecutable:
./gradlew :playground:jsBrowserDistributionThe tests run in a real browser (./gradlew :playground:jsBrowserTest). A handful measure layout
and input, which a DOM emulation would answer for rather than measure; the rest are ordinary Kotlin
and run there because the target declares browser() alone, leaving no Node lane to put them on.
JsonLogic() registers all 34 operations below by default, in the same order as the engine this
library ports. var is also fully supported as a first-class part of the rule syntax, rather than a
registered operation, so it isn't counted among the 34.
- Numeric (11):
+-*/%minmax>>=<<= - Logic & boolean (10):
if/?:==!====!==!!!andor - Array (8):
mapfilterreduceallsomenonemergein - String (2):
catsubstr - Data access (2):
missingmissing_some - Miscellaneous (1):
log
Full semantics for each operation are documented at jsonlogic.com/operations.html.
Releases are published to GitHub Packages (not Maven Central). GitHub Packages requires
authentication to resolve Maven artifacts even from a public repository, so add credentials
alongside the repository. The Gradle property names are yours to pick — they carry no meaning to
GitHub, and only have to match what your ~/.gradle/gradle.properties calls them:
// settings.gradle.kts or build.gradle.kts
repositories {
maven {
url = uri("https://maven.pkg.github.com/BranchIntl/json-logic-kmp")
credentials {
// A personal access token with the read:packages scope, e.g. from ~/.gradle/gradle.properties
// or the GITHUB_ACTOR/GITHUB_TOKEN environment variables in CI.
username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR")
password = providers.gradleProperty("gpr.token").orNull ?: System.getenv("GITHUB_TOKEN")
}
}
}Then declare the dependency. In a Kotlin Multiplatform project, add it to commonMain so every
target picks it up:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("co.branch.jsonlogic:json-logic-kmp:0.3.0")
}
}
}Gradle's Kotlin Multiplatform metadata resolves the correct platform artifact for each source set automatically; a single coordinate covers every target.
For a JVM- or Android-only consumer (not a multiplatform module), the plain top-level form also works:
dependencies {
implementation("co.branch.jsonlogic:json-logic-kmp:0.3.0")
}import co.branch.jsonlogic.JsonLogic
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
val jsonLogic = JsonLogic()
val result = jsonLogic.apply(
"""{"var": "a"}""",
buildJsonObject { put("a", 1) },
)
// result.jsonPrimitive.content == "1"The data parameter is always a JsonElement? — there is no overload that takes a data string.
If your data arrives as serialized JSON rather than a JsonElement, parse it first:
import kotlinx.serialization.json.Json
val data = Json.parseToJsonElement("""{"a": 1}""")
jsonLogic.apply("""{"var": "a"}""", data)apply(rule, ...) parses the rule fresh on every call. There is no internal parse cache, so parse a
rule once with parse() and reuse the resulting node whenever you evaluate it repeatedly:
val rule = jsonLogic.parse("""{"===": [{"var": "a"}, 1.0]}""")
jsonLogic.apply(rule, buildJsonObject { put("a", 1) }) // true
jsonLogic.apply(rule, buildJsonObject { put("a", 2) }) // falseparse takes a rule in either of the forms apply accepts, so a rule that already is a
JsonElement — one that arrived inside a larger payload, say — pre-parses without being serialized
back to text first:
val payload = Json.parseToJsonElement("""{"rule": {"===": [{"var": "a"}, 1.0]}}""").jsonObject
val rule = jsonLogic.parse(payload.getValue("rule"))The absence of a cache is deliberate. Only you know what identifies a rule — a screen id, a field
name, a hash — and a cache inside a JsonLogic instance would make it mutable, undoing the property
that lets one configured instance be shared across threads. A Map from your own rule key to the
parsed node, populated as rules arrive, is the whole of what a cache needs to be here.
Register a custom operation from a plain function over its already-evaluated arguments — the convenience form for an operation that doesn't need to control which arguments are evaluated or in what data context:
jsonLogic.addOperation("greet") { args -> "Hello, ${args[0]}!" }
jsonLogic.apply("""{"greet": ["world"]}""") // "Hello, world!"For full control, implement JsonLogicExpression (or its PreEvaluatedArgumentsExpression
convenience subtype) directly:
import co.branch.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression
jsonLogic.addOperation(object : PreEvaluatedArgumentsExpression {
override val key: String = "double"
override fun evaluate(arguments: List<Any?>, data: Any?, jsonPath: String): Any? =
(arguments[0] as Double) * 2
})
jsonLogic.apply("""{"double": [21]}""") // 42.0Registering under a key that's already registered — including one of the 34 defaults — replaces it: whichever registration happens last wins. Both overloads rebuild the evaluator eagerly, so batch custom-operation registration during setup rather than per request.
JsonLogic.truthy mirrors JsonLogic's own truthiness rules (matching JavaScript's, not Kotlin's):
JsonLogic.truthy(0) // false
JsonLogic.truthy(1) // true
JsonLogic.truthy("") // false
JsonLogic.truthy("Hello world!") // truetruthy takes Any?, so it also accepts values the engine itself never produces, such as a Kotlin
or Java array — see Known deviations & sharp edges below for how
that case behaves.
- Unchecked exceptions.
JsonLogicExceptionextendsRuntimeException; upstream's version is a checked exception that every caller must declare or catch. Notry/catchorthrowsclause is needed to call into this library. - No parse cache;
addOperationrebuilds eagerly. Parse a rule once viaparse()and reuse it rather than re-parsing on everyapply(rule, ...)call. Finish configuring an instance (default registrations plus anyaddOperationcalls) on one thread before sharing it; once configuration is complete and the instance is safely published, concurrentapplycalls are safe, but concurrentaddOperationcalls — with each other or with an in-flightapply— are not. - Strict JSON parsing. Rule strings are parsed as strict JSON via
kotlinx.serialization. Upstream's Gson-backed parser was lenient (unquoted keys and similar); those inputs no longer parse. - Numbers are always
Double, and render the way JavaScript writes them. Every numeric value is normalized toDoubleinside the engine, so integers beyond 2^53 lose precision. Results cross back into JSON through ECMAScript'sNumber::toString, byte-identical on every platform: a whole number carries no decimal point ({"+": [1, 2]}is3), the plain-decimal range is[1e-6, 1e21), and everything outside it is exponential (1e+21,1e-7).cat,substrand the substring testinperforms against a string render a number the same way, so{"in": [1, "a1b"]}istrue. One place still renders one with Java'sDouble.toString:log's diagnostic text, which is not a value a rule can go on to compare. - Infinity and NaN aren't valid JSON. A result of positive infinity, negative infinity, or NaN
(e.g. from
{"/": [1, 0]}) is returned as aJsonElementholding that literal, unquoted text, since JSON has no token for it. Reading it back out in Kotlin works fine, but re-encoding it through a standard JSON writer produces text most JSON parsers reject. catdrops a null argument;substrandinrender one as the textnull. The reference joinscat's arguments withArray.prototype.join, which renders null as the empty string, and stringifiessubstr's source andin's needle withString(), which renders either as"null"— so{"substr": [null, 1]}is"ull"and{"in": [null, "a null value"]}istrue.catandsubstrdiverge from upstream, which throwsNullPointerExceptionin either case;indiverges from it by looking for the text at all rather than answeringfalseon sight of a null needle.substrclamps every offset into range and likewise never throws for one.- Preserved upstream quirks:
all's error jsonPath always reports[1]for the failing element, regardless of its actual index;substrandmissing_sometype-check their numeric arguments asDoubleinternally; avar's default-value expression is evaluated twice when its key resolves to null;missing's dotted-key flattening descends only into nested objects, never into arrays. - One accepted behavioral difference. Comparing the raw result of
missingwith===orinis structural here ({"===": [{"missing": ["a", "b"]}, ["a", "b"]]}istrue). Upstream wraps that result in an internal type whoseequalsalways returnsfalse, making the same comparisonfalsethere — and asymmetrically so, since the reverse operand order istrue. Unreachable through the standard fixture corpus; accepted deliberately rather than reproduced. - Rule nesting is bounded; a hand-built node is not. Parsing and evaluating both recurse, so every
parseandapplyoverload that takes a rule as JSON rejects one nested deeper thanJsonLogicParser.DEFAULT_MAX_DEPTH(128 objects and arrays —{"+": [1, 2]}is 2 levels, and each operator around it adds 2).parse(rule, maxDepth)moves the bound. AJsonLogicNodeyou assemble yourself and hand toapplyhas been through no such check, and neither has the data a rule runs against — converting that into the engine's value domain walks the whole tree recursively. - A value that contains itself is named, not entered.
reducehands its reducer a single context map and mutates it in place, so a reducer returning its own data — or a list built around it — leaves a cycle in the value it returns.cat,substr,inandlogrender a container reached from inside itself as(this Map)or(this Collection), at whatever depth it recurs. The names arejava.util's, but the reach is not:java.utilcompares an entry only against the container directly holding it, and overflows the stack on a cycle closing through two of them. truthyon a Kotlin/Java array differs from upstream. Values parsed from rules orJsonElementdata are never arrays (onlyList,Map,String,Number,Boolean, andnullever reach an expression that way), sotruthyhas no case for one and it falls through to the default branch, returningtrue— even for an empty array — where upstream's Java duck-typing treated an empty array as falsy. Convert to aListfirst if you need array truthiness. A custom operation can still introduce an array:addOperation's function type returns an unvalidatedAny?, and whatever it returns flows straight into any surrounding expression. Return domain values from custom operations —Listrather than an array, plusDouble/String/Boolean/null/Map— or an array result will reach a nested expression unconverted and hit this same truthiness divergence.
Contributions are welcome. Building and testing this repository — prerequisites, the playground's bundled font, and the four CI lanes — is in CONTRIBUTING.md. Cutting a release is in PUBLISHING.md.
MIT — see LICENSE. Original work © 2018 Sam Jesso (jamsesso/json-logic-java); this repository is a Kotlin Multiplatform port of that work.
The playground bundles JetBrains Mono, under the SIL
Open Font License 1.1. Its licence travels with it, in
playground/src/jsMain/resources/fonts/JetBrainsMono-OFL.txt and on the deployed site.